普通视图

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

前端最新面试题

2025年4月18日 13:02

以下是一些最新的前端面试题及其答案:

一、Vue.js相关问题

  1. 什么是Vue.js 3中的Composition API?

    • 答案:Composition API 是 Vue 3 引入的一种新的编写组件逻辑的方式,通过 setup 函数来组织代码。它允许更灵活地复用逻辑和更好的类型推断。
  2. Vue.js 3中的 refreactive 有什么区别?

    • 答案ref 用于创建单个响应式引用,适用于基本数据类型;reactive 用于创建复杂的数据结构(如对象或数组),适用于引用类型。
  3. Vue.js 3中的 Teleport 组件有什么作用?

    • 答案Teleport 允许将组件的一部分渲染到 DOM 中的其他位置,如模态框、弹窗等,解决了组件层级限制问题。
  4. 什么是 Vue.js 3 中的 v-memo 指令?

    • 答案v-memo 用于缓存子树的渲染结果,只有当依赖项发生变化时才会重新渲染,适用于大型列表或复杂组件的性能优化。
  5. Vue.js 3中的 onMountedonUnmounted 生命周期钩子如何使用?

    • 答案:在 setup 函数中通过导入对应的生命周期钩子函数来使用,例如:
      import { onMounted, onUnmounted } from 'vue';
      
      export default {
        setup() {
          onMounted(() => {
            console.log('组件已挂载');
          });
      
          onUnmounted(() => {
            console.log('组件已卸载');
          });
        }
      }
      

二、React相关问题

  1. React 19 引入的并发模式是什么?

    • 答案:并发模式优化了渲染优先级,提升交互流畅度,例如长列表分页加载时优先响应用户操作。
  2. React 中的 useStateuseReducer 有什么区别?

    • 答案useState 用于管理简单的状态,而 useReducer 用于管理复杂的状态逻辑,通常适用于多个状态管理的场景。

三、前端工程化相关问题

  1. 什么是前端工程化?它的核心要素有哪些?

    • 答案:前端工程化是将软件工程的原理和方法应用到前端开发中,以提高开发效率、代码质量和可维护性。核心要素包括模块化、自动化、CI/CD、版本管理与依赖管理、代码质量与规范、性能优化、跨浏览器和设备兼容性。
  2. 如何使用 Webpack 进行代码分割和懒加载?

    • 答案:使用 Webpack 的 SplitChunksPlugin 进行代码分割,使用 import() 实现按需加载。例如:
      import(/* webpackChunkName: "lodash" */ 'lodash').then(({ default: _ }) => {
        // 使用 _
      });
      

四、性能优化相关问题

  1. 如何优化 React 应用中的大量数据渲染性能?
    • 答案:使用虚拟化列表(如 react-windowreact-virtualized)技术,以及 React.memo 避免不必要的组件重新渲染。

一分钟吃透一道面试算法题——字母异位词分组(最优解)

作者 天天扭码
2025年4月18日 12:58

一、前置知识(可跳过)

1.质数分解的唯一性

算术基本定理:任何大于1的自然数,要么本身就是一个质数,要么可以唯一地分解成一系列质数的乘积。

在字母异位词分组的场景中, 我们将每个字母映射到一个唯一的质数,然后将字符串中所有字母对应的质数相乘。 由于质数分解的唯一性, 确保了只有字母异位词(它们所包含的字符完全相同,只是顺序不同)才会有相同的质数乘积。 这使得我们可以使用这个质数乘积作为键来将字母异位词分组。 这是为什么这种方法可以有效地将字母异位词分组的关键原因。

二、题目描述

给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。

字母异位词 是由重新排列源单词的所有字母得到的一个新单词。

示例 1:

输入: strs = ["eat", "tea", "tan", "ate", "nat", "bat"]
输出: [["bat"],["nat","tan"],["ate","eat","tea"]]

示例 2:

输入: strs = [""]
输出: [[""]]

示例 3:

输入: strs = ["a"]
输出: [["a"]]

 

提示:

  • 1 <= strs.length <= 104
  • 0 <= strs[i].length <= 100
  • strs[i] 仅包含小写字母

三、题解

var groupAnagrams = function(strs) {
    const anagramGroups = new Map();

    for (const str of strs) {
        // Optimized Key Generation: Prime Number Product
        let key = 1;
        for (let i = 0; i < str.length; i++) {
            const charCode = str.charCodeAt(i) - 'a'.charCodeAt(0);
            // Prime numbers mapped to characters (a=2, b=3, c=5, etc.)
            const prime = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101][charCode];
            key *= prime;
        }
        // Empty string case:
        if (str.length === 0) key = 0; //Crucial for handling empty strings

        if (anagramGroups.has(key)) {
            anagramGroups.get(key).push(str);
        } else {
            anagramGroups.set(key, [str]);
        }
    }

    return Array.from(anagramGroups.values());
};

核心思想

字母异位词包含相同的字符,只是字符的顺序不同。如果我们能将每个字母异位词映射到一个唯一的“指纹”(或者说键),那么我们就可以用这个键来将它们分组。 这里使用的关键在于将每个字母映射到一个唯一的质数,然后将字符串中所有字母对应的质数相乘,得到的乘积就作为这个字符串的键。 由于质数分解的唯一性,确保了只有字母异位词才会有相同的键。

详细解析

/**
 * @param {string[]} strs
 * @return {string[][]}
 */
var groupAnagrams = function(strs) {
    const anagramGroups = new Map();
  • 这部分是标准的函数定义和初始化。 anagramGroups 是一个 Map 对象,用于存储最终的结果。Map 对象的 key 是字符串对应的质数乘积,value 是包含相同字母异位词的数组。
    for (const str of strs) {
  • 遍历输入的字符串数组 strs
        // Optimized Key Generation: Prime Number Product
        let key = 1;
  • 初始化 key 为 1。注意,这里必须从 1 开始,因为任何数乘以 1 还是它本身。 key 将用于存储当前字符串 str 的质数乘积。
        for (let i = 0; i < str.length; i++) {
            const charCode = str.charCodeAt(i) - 'a'.charCodeAt(0);
            // Prime numbers mapped to characters (a=2, b=3, c=5, etc.)
            const prime = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101][charCode];
            key *= prime;
        }
  • 这个循环遍历当前字符串 str 中的每个字符。

    • const charCode = str.charCodeAt(i) - 'a'.charCodeAt(0);:计算字符的 ASCII 码, 并减去 'a' 的 ASCII 码,得到字符在字母表中的索引(0-25)。 例如,'a' 的 charCode 是 0, 'b' 的 charCode 是 1,以此类推。
    • const prime = [2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 37, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101][charCode];: 使用预定义的质数数组,将每一个字母映射到一个唯一的质数。 'a' 映射到 2, 'b' 映射到 3, 'c' 映射到 5,以此类推。
    • key *= prime;: 将当前字符对应的质数乘以 key。 这样,key 就逐步累积了字符串中所有字符对应的质数的乘积。
        // Empty string case:
        if (str.length === 0) key = 0; //Crucial for handling empty strings
  • 关键步骤:  处理空字符串的情况。 如果字符串为空 (str.length === 0), 则将 key 设置为 0。 这样做很重要,原因如下:

    • 如果不特殊处理,空字符串的 key 仍然是 1(因为初始值是 1,并且没有乘以任何质数)。 这会导致所有空字符串都被错误地分组到一起,因为它们都具有相同的 key(即 1)。
    • 将空字符串的 key 设置为 0,确保了空字符串与其他任何非空字符串都能正确区分开。
        if (anagramGroups.has(key)) {
            anagramGroups.get(key).push(str);
        } else {
            anagramGroups.set(key, [str]);
        }
  • 这部分与之前的代码类似,用于将具有相同 key 的字符串分组。

    • if (anagramGroups.has(key)):检查 Map 中是否已经存在具有相同 key(相同质数乘积)的条目。
    • anagramGroups.get(key).push(str);:如果存在, 则将当前字符串 str 添加到该 key 对应的数组中。
    • else { anagramGroups.set(key, [str]); }: 如果不存在, 则创建一个新的 key,并将当前字符串 str 作为一个新的数组添加到 Map 中。
    }
    return Array.from(anagramGroups.values());
};
  • 循环结束后,anagramGroups包含了所有分组好的字母异位词。 Array.from(anagramGroups.values()) 将 Map 中的 values (包含字母异位词的数组) 转换为一个数组,并返回。

实例与展示

image.png

image.png

四、结语

再见!

在 AI 编程的热潮下对低代码的思考

作者 何遇er
2025年4月18日 12:48

近期,DeepSeek的火爆引发了对AI编程领域的讨论。在此背景下,我阅读了一篇题为《低代码已死?试看国产AI编程工具Trae如何破局》的微信公众号推文,我曾在就职的公司落地低代码平台,并出版了《低代码平台开发与实践:React》。在 AI 编程的热潮下,本文是我对低代码的思考。

低代码平台的核心是高效开发,其次是低门槛,通过简化开发流程和抽象技术细节来实现高效开发和低门槛,具体的措施是:

  1. 可视化构建:将开发过程从手动编码升维为配置与组合
  2. 组件化复用:将复杂系统拆解为可复用的单元,组件化依赖的封装性和接口标准化
  3. 自动化部署:致力于“减少人工干预”,其本质是“代码到生产环境的自动化迁移”

国内低代码平台(包括商业服务商和开源项目)在宣传中更侧重可视化构建和组件化复用,而对自动化部署的强调相对较弱。这一现象背后有多层原因:

  1. 可视化能力、组件库和行业模板是吸引非开发者用户的核心。投入大量资源优化拖拽体验、丰富组件生态,可以快速占领市场。
  2. 自动化部署的技术复杂性高。
  3. 国内大部分低代码用户是业务部门(如HR、财务),而非技术团队。相比自动化部署,他们更关心自己能否动手开发,而非自动化部署。

对低代码可视化构建的一个常见误区是过度强调拖拽功能。但实际上,拖拽只是低代码平台的一种输入方式,当前正向更自然、更灵活的方向演进。目前,低代码平台的输入方式有:

  1. 拖拽式交互
  2. 代码嵌入
  3. 声明式配置

当前正在演进,未来可能出现的方式:

  1. 自然语言描述
  2. 语音与手势
  3. 脑机接口

技术革新会使交互方式发生剧烈变化,但组件化复用和自动化部署作为低代码平台的核心支柱,其内核逻辑和技术价值并未改变。原因如下:

  1. 组件化复用的本质是将复杂系统拆解为可复用的单元,组件化依赖的封装性和接口标准化,未发生根本变化,因此交互方式的革新不会动摇其根基。
  2. 自动化部署始终致力于减少人工干预,其本质是代码到生产环境的自动化迁移。CI/CD流水线、基础设施即代码等核心理念未变,因此交互方式的变化不会颠覆其逻辑。

立足于当下,AI 对低代码的影响主要是交互方式,具体上来说是扩充交互方式。开发过低代码的人,在 AI 编程的热潮下,可能会问 AI 编程是否会替代低代码,我觉得不会,原因如下:

  1. 用户群体不同:AI 编程对用户的编程能力要求更高,低代码的用户主要是产品经理和业务人员,AI编程用户主要是程序员和技术极客。

  2. 能力范围不同:低代码平台涵盖开发→测试→部署→监控的全流程,内置版本控制和一键回滚,而 AI 编程主要聚焦于代码生成环节。

  3. 业务分级处理能力:AI 编程通常由个人独立完成任务,而低代码支持分工协作,有助于节省高级人才的时间。简单来说,AI 编程和传统编程一样,都是一个人从头到尾做一道菜,而低代码像流水线分工,不同的人负责不同的步骤。

  4. 统一性优势:程序员普遍面临一个难题:接手别人的代码就像拆盲盒。你打开别人写的代码文件:

    • 左边是密密麻麻的代码结构
    • 右边是他的代码风格。

    因此程序员不愿意接手别人的代码。而低代码平台就像给代码装上了说明书:

    • 代码结构可视化展示
    • 平台强制规定所有组件的拼接方式,避免风格混乱。

目前,多数低代码平台依赖预制组件来加速开发,这容易导致应用同质化,这种模板化的解决方案容易使产品缺乏差异化,如果生成式 AI 可以动态生成更符合具体业务需求的组件,从而提高个性化和灵活性,那么这会为低代码平台带来质的飞跃。

一句 Prompt 自动生成表单:我在低代码平台里是怎么接入生成式 AI 的

一句 Prompt 自动生成表单:我在低代码平台里是怎么接入生成式 AI 的

作者 何遇er
2025年4月18日 12:46

在过去的低代码平台中,用户通常通过拖拽组件完成页面搭建。但当生成式 AI 出现之后,我们逐渐意识到:对很多人来说,“拖拽”依然太重了,他们更希望的是——一句自然语言就能完成整个页面的构建。

比如:“帮我添加一个输入姓名的输入框和一个性别选择下拉框,选项为男、女。”

我是《低代码平台开发实践:基于 React》一书的作者,本文将结合我实际项目经验,介绍如何将生成式 AI 接入已有的低代码平台,并让 AI 理解组件规范、自动输出结构化的 Schema,最终在画布中渲染出页面。

本文内容分为三部分:

  1. 如何构建 Prompt 模板
  2. 如何调用 AI 模型,获取组件 Schema
  3. 如何将返回的 Schema 插入画布

背景:已有低代码平台的能力

本文基于一个已有的低代码平台,具备以下特性:

  1. 已实现基础组件体系;
  2. 用户可通过拖拽组件至画布;
  3. 组件支持属性编辑。

此前我在 《在 AI 编程的热潮下对低代码的思考》 一文中提到:AI 对低代码最直接的冲击,是“交互方式”的变革。这次我将以“自然语言生成表单页面”为例,展示具体的接入过程。

第一步:如何构建 Prompt 模板

为了让 AI 能够输出符合平台要求的 JSON Schema,首先要提供组件的 使用规范输出格式定义。也就是说,你得教它平台的规则。

在我的低代码平台中,每个组件都有对应的组件规格(ComponentSpec),定义如下:

interface  {
  componentName: string;
  packageName: string;
  title: string;
  iconUrl: string;
  description: string;
  docUrl?: string;
  version: string;
  props: PropRaw[];
  // 描述该组件位于组件面板中哪个区域
  group?: "base"|"layout"|"subjoin"|"template";
  advanced?: {
    // 组件的嵌套规则
    nestingRule?: {
      // 父级组件白名单
      // 非容器组件必须放置在容器组件中
      parentWhitelist?: string[];
      // 子组件白名单。
      // 空数组则说明其他组件不能放置在该组件中, undefined 则说明其他组件能放置在该组件中
      childWhitelist?: string[];
    };
    supports?: {
      // 是否能配置样式
      styles?: boolean;
      // 支持的事件列表,空数组意味着不支持任何事件
      events?: string[]
    },
    component?: {
      // 是否是容器
      isContainer?: boolean;
      // 容器类型
      containerType?: 'Layout'|'Data'|'Page';
      // 是否是表单组件
      isFormControl?: boolean
    },
  },
  // 嵌套的组件规格,通常只有模板才有这个字段
  // 模板所嵌套的组件的嵌套规则不会被用到
  // 注意:children 中的组件,必须在引擎中注册
  children?: ComponentSpecRaw
}

你可以在这里查看 Input 组件的完整规格:

📎 unpkg.com/vitis-lowco…

基于组件规格,我们需要 AI 输出如下类型的 schema:

interface NodeSchema {
  componentName: string;
  packageName: string;
  props: {[key: string]: any};
  extraProps: ExtraProps;
  isContainer: boolean;
  isFormControl?: boolean;
  containerType?: 'Layout'|'Data'|'Page';
  children: NodeSchema[];
}
  • componentName:组件名,比如 Input、Select 等,从组件规格中能得到该值。
  • packageName:组件 npm 包名,比如 vitis-lowcode-input 等,从组件规格中能得到该值。
  • isContainer:表明该组件是否是容器组件,从组件规格中能得到该值。
  • isFormControl:表明该组件是否是表单控件,从组件规格中能得到该值。
  • containerType:表明该组件属于哪一种容器组件,取值为 'Layout'|'Data'|'Page',从组件规格中能得到该值。
  • children:子组件
  • props:在组件面板中可编辑的字段以及默认值。生成规则为:
const props: {[attr: string]: any} = {}
// componentSpecRaw 为组件规则
componentSpecRaw.props.forEach(prop => {
   props[prop.name] = prop.defaultValue
 })
  • extraProps:在组件面板中可编辑的字段以及默认值。生成规则为:
function initExtraProps(rawData: ComponentSpecRaw) {
      // 将取值路径、name 和 id 放在 extraProps 中
      const extraProps = {
          id: {
              type: 'JSRunFunction',
              value: "node => node.id"
          }
      }

      if (rawData.advanced?.component?.containerType !== 'Page') {
          extraProps.pathToVal = ''
      }

      if (rawData.advanced?.component?.isFormControl) {
          extraProps.name = ''
      }

      if (rawData.advanced?.component?.isContainer) {
          extraProps.dataSource = {
              type: 'DataSource',
              value: {
                  url: '',
                  method: 'GET',
                  requestHandler: {
                      type: 'JSFunction',
                      value: 'function requestHandler(params){return params}'
                  },
                  responseHandler: {
                      type: 'JSFunction',
                      value: 'function responseHandler(response) { return response.data }'
                  }
              }
          }
      }

      if (!rawData.advanced?.component?.isContainer || rawData.advanced.component.containerType !== 'Page') {
          extraProps.isHidden = {
              type: 'JSFunction',
              value: 'function isHidden(pageData, containerData, formData){ return false }'
          }
      }

      if (rawData.advanced?.component?.isFormControl) {
          extraProps.isDisabled = {
              type: 'JSFunction',
              value: 'function isDisabled(pageData, containerData, formData){ return false }'
          }

          extraProps.getValue = {
              type: 'JSFunction',
              value: ''
          }
      }

      return extraProps
  }

上述代码可在 github.com/react-low-c… 找到。

Prompt 模板包含哪些内容?

Prompt 模板是发送给 AI 的提示语,它包含以下 7 个关键点:

  1. AI 的角色

    你是一个低代码平台 AI 助手,能够根据用户的自然语言描述,生成符合平台组件规范的页面 Schema。你输出的结果应是一个 JSON 数组,代表组件树的根节点

  2. 输出格式要求

    直接返回 JSON schema,格式遵循规定,不要输出解释或注释

  3. Schema 字段要求

    每个组件需要输出一个完整的 JSON Schema 节点,包含以下字段:containerType、componentName、packageName、props、extraProps、isContainer、isFormControl、children。

  4. 可用组件列表

    你只能使用以下组件:Input(输入框)、Select(下拉框)、Row(行)、Column(列)

  5. 组件嵌套规则

    遵守以下组件嵌套规则:Input 和 Select 只能放在 Column 中;Column 只能放在 Row 中;Row 是页面的顶层布局容器;

  6. props 与 extraProps 的默认值与结构生成规则

  7. 各组件的 componentName、packageName 等元信息

Prompt 模板可由后端自动遍历组件规格生成。

第二步:如何调用 AI 模型生成 Schema

我使用 Node.js 配合 302.ai 的 API 来请求 AI 生成结果。核心代码如下:

import axios from "axios";

const instance = axios.create({
  baseURL'https://api.302.ai',
  headers: {
    "Content-Type"'application/json',
    "Authorization"'Bearer ' + 'YOUR-API-KEY'
  }
});

export function fetchSchema(userPromptstring) {
  return instance.request({
    url'/v1/chat/completions',
    method'POST',
    data: {
      model'gemini-1.5-pro',
      messages: [
        { role'system', content: prompt },
        { role'user', content: userPrompt }
      ]
    }
  }).then(res => res.data.choices[0].message.content);
}

用户输入自然语言(如:“帮我生成一个页面,包含一个姓名输入框和性别选择下拉框”),最终会被传入 fetchSchema 方法,由 AI 返回符合组件规范的 JSON Schema。


第三步:如何将 Schema 插入画布

生成好的 schema 需要插入低代码平台的画布中。我们在核心类 DocumentModel 中新增 insertSchema 方法,它接受两个参数:

  1. schemas:AI 模型的返回值
  2. parentNode:schemas 的父节点
insertSchema(schemas: NodeSchema[], parentNode: Node<NodeSchema> = this.rootNode) {
  const insert = (schemas: NodeSchema[], parentNode: Node<NodeSchema>) => {
    schemas.forEach((schema, index) => {
      const newNode = this.createNode(schema, parentNode);
      parentNode.inertChildAtIndex(newNode, parentNode.childrenSize + index);
    });
  }

  insert(schemas, parentNode)
  // 重新渲染画布
  this.project.renderer?.rerender();
}

这个方法支持插入复杂组件树,同时调用引擎的 rerender() 方法刷新视图。


结语

如果你正在开发自己的低代码平台,或对接入 AI 有更多探索,欢迎和我交流。我的图书《低代码平台开发实践:基于 React》全面介绍了低代码引擎、渲染器、代码生成等核心模块。想加入【低代码 + AI 实战交流群】,请关注我的公众号——前端知识小站,并联系我。

Vue 指令模块深度剖析:从基础应用到源码级解析(十二)

2025年4月18日 12:40

Vue 指令模块深度剖析:从基础应用到源码级解析

本人掘金号,欢迎点击关注:掘金号地址

本人公众号,欢迎点击关注:公众号地址

一、引言

在 Vue.js 的生态体系中,指令作为其核心特性之一,为开发者提供了强大且灵活的 DOM 操作能力。通过指令,开发者可以在模板中快速实现诸如条件渲染、循环渲染、事件绑定、样式控制等功能。与普通的 HTML 属性不同,Vue 指令以v-为前缀,能够响应数据的变化并动态更新 DOM。本文将从源码层面深入分析 Vue 的指令模块,涵盖指令的注册、解析、生命周期钩子执行等核心流程,帮助开发者全面理解 Vue 指令的运行原理。

二、Vue 指令基础概念

2.1 指令的定义与作用

Vue 指令(Directive)是一种带有v-前缀的特殊属性,用于在模板中对 DOM 元素进行特定操作。例如,v-show用于控制元素的显示与隐藏,v-bind用于动态绑定 HTML 属性,v-on用于绑定事件监听器。指令的核心作用在于将数据与 DOM 进行关联,并在数据变化时自动更新 DOM 状态。

2.2 内置指令与自定义指令

Vue 提供了多个内置指令,例如:

  • v-bind:用于动态绑定 HTML 属性,如v-bind:classv-bind:style

  • v-on:用于绑定事件监听器,如v-on:clickv-on:keyup

  • v-show:根据表达式的值决定元素是否显示(通过修改display属性)。

  • v-if:根据表达式的值决定元素是否渲染到 DOM 中。

  • v-for:用于循环渲染数组或对象。

除了内置指令,开发者还可以通过Vue.directive方法创建自定义指令,以满足特定的业务需求。

三、Vue 指令的注册机制

3.1 内置指令的注册

在 Vue 的初始化过程中,内置指令会被自动注册。以下是简化的源码片段,展示了部分内置指令的注册逻辑:

javascript

// src/core/global-api/directives.js

// 定义v-model指令的处理逻辑
const model = {
  bind (el, binding, vnode) {
    // 初始化绑定,例如创建input事件监听器
    // 绑定的value为binding.value,修饰符为binding.modifiers
    const value = binding.value;
    const modifiers = binding.modifiers;
    // 处理不同类型的表单元素
    if (vnode.tag === 'input' && modifiers.number) {
      el.addEventListener('input', e => {
        const num = Number(e.target.value);
        vnode.context[binding.expression] = isNaN(num) ? '' : num;
      });
    } else {
      el.addEventListener('input', e => {
        vnode.context[binding.expression] = e.target.value;
      });
    }
  },
  update (el, binding, vnode) {
    // 更新绑定值,同步DOM与数据
    const value = binding.value;
    if (vnode.tag === 'input' && binding.modifiers.number) {
      el.value = isNaN(value) ? '' : value;
    } else {
      el.value = value;
    }
  }
};

// 注册v-model指令
Vue.directive('model', model);

// 定义v-bind指令的处理逻辑
const bind = {
  bind (el, binding, vnode) {
    // 处理绑定的属性,例如class、style等
    const name = binding.arg;
    const value = binding.value;
    if (name === 'class') {
      // 处理class绑定
      if (typeof value === 'object') {
        for (const key in value) {
          if (value[key]) {
            el.classList.add(key);
          } else {
            el.classList.remove(key);
          }
        }
      } else if (typeof value ==='string') {
        el.classList.add(value);
      }
    } else if (name ==='style') {
      // 处理style绑定
      if (typeof value === 'object') {
        for (const prop in value) {
          el.style[prop] = value[prop];
        }
      }
    }
  },
  update (el, binding, vnode) {
    // 更新绑定属性的值
    const name = binding.arg;
    const value = binding.value;
    if (name === 'class') {
      if (typeof value === 'object') {
        for (const key in value) {
          if (value[key]) {
            el.classList.add(key);
          } else {
            el.classList.remove(key);
          }
        }
      } else if (typeof value ==='string') {
        el.classList.add(value);
      }
    } else if (name ==='style') {
      if (typeof value === 'object') {
        for (const prop in value) {
          el.style[prop] = value[prop];
        }
      }
    }
  }
};

// 注册v-bind指令
Vue.directive('bind', bind);

// 定义v-on指令的处理逻辑
const on = {
  bind (el, binding, vnode) {
    // 绑定事件监听器
    const eventName = binding.arg;
    const handler = binding.value;
    el.addEventListener(eventName, handler);
  },
  unbind (el, binding, vnode) {
    // 移除事件监听器
    const eventName = binding.arg;
    const handler = binding.value;
    el.removeEventListener(eventName, handler);
  }
};

// 注册v-on指令
Vue.directive('on', on);

上述代码展示了v-modelv-bindv-on三个内置指令的注册过程。每个指令通过定义bindupdateunbind等钩子函数,来处理指令在不同阶段的逻辑。

3.2 自定义指令的注册

开发者可以通过Vue.directive方法注册自定义指令。示例如下:

javascript

// 注册一个自定义指令v-focus,用于自动聚焦元素
Vue.directive('focus', {
  inserted: function (el) {
    // 元素插入DOM后自动聚焦
    el.focus();
  }
});

// 在模板中使用自定义指令
<template>
  <input v-focus type="text">
</template>

Vue.directive方法接收两个参数:指令名称(字符串)和指令定义对象。指令定义对象可以包含bindinsertedupdatecomponentUpdatedunbind等钩子函数。

四、指令在模板编译中的解析过程

4.1 模板编译与指令提取

当 Vue 编译模板时,会通过@vue/compiler-dom库将模板字符串转换为 AST(抽象语法树),并识别出其中的指令。以下是简化的编译流程源码:

javascript

// @vue/compiler-dom/src/parser/index.ts

// 解析模板字符串为AST
function parse(template: string): ASTElement {
  const stack: ASTElement[] = [];
  const root: ASTElement | null = null;
  let currentParent: ASTElement | null = null;
  let index = 0;

  function createASTElement(tag: string, attrs: Attr[]): ASTElement {
    return {
      type: 1, // 元素类型
      tag,
      attrs,
      children: [],
      parent: currentParent
    };
  }

  function processDirectives(node: ASTElement) {
    // 遍历元素属性,提取指令
    const directives: Directive[] = [];
    for (const attr of node.attrs) {
      if (attr.name.startsWith('v-')) {
        const name = attr.name.slice(2); // 去除v-前缀
        const value = attr.value;
        directives.push({
          name,
          value,
          modifiers: getModifiers(name)
        });
      }
    }
    node.directives = directives;
  }

  while (index < template.length) {
    // 解析标签开始、结束、文本等
    //...

    if (tag) {
      const element = createASTElement(tag, attrs);
      processDirectives(element);
      if (!root) {
        root = element;
      }
      if (currentParent) {
        currentParent.children.push(element);
      }
      stack.push(element);
      currentParent = element;
    }

    //...
  }

  return root;
}

// 解析指令修饰符
function getModifiers(name: string): Record<string, boolean> {
  const modifiers: Record<string, boolean> = {};
  const parts = name.split('.');
  if (parts.length > 1) {
    for (let i = 1; i < parts.length; i++) {
      modifiers[parts[i]] = true;
    }
    return modifiers;
  }
  return {};
}

上述代码展示了模板解析过程中指令的提取逻辑。通过遍历元素属性,识别以v-开头的属性,并将其转换为指令对象。

4.2 指令生成渲染函数

在模板编译的后期阶段,指令信息会被转换为渲染函数中的代码。例如,v-show指令会被编译为条件判断语句:

javascript

// @vue/compiler-dom/src/codegen/index.ts

function generate(node: ASTElement): string {
  if (node.directives) {
    for (const directive of node.directives) {
      if (directive.name ==='show') {
        // 生成v-show的渲染逻辑
        return `(${directive.value})? ${generateChildren(node)} : null`;
      }
    }
  }
  // 生成普通元素的渲染代码
  return `<${node.tag}${generateAttrs(node)}>${generateChildren(node)}</${node.tag}>`;
}

function generateChildren(node: ASTElement): string {
  let code = '';
  for (const child of node.children) {
    if (child.type === 1) {
      code += generate(child);
    } else if (child.type === 3) {
      code += `'${child.text}'`;
    }
  }
  return code;
}

function generateAttrs(node: ASTElement): string {
  let code = '';
  for (const attr of node.attrs) {
    if (attr.name.startsWith('v-')) {
      // 处理指令属性,生成对应代码
      //...
    } else {
      code += ` ${attr.name}="${attr.value}"`;
    }
  }
  return code;
}

上述代码将v-show指令转换为 JavaScript 条件表达式,在渲染时根据表达式的值决定是否渲染元素。

五、指令的生命周期钩子

5.1 bind 钩子

bind钩子在指令第一次绑定到元素时调用,仅会执行一次。它可以用于初始化操作,例如绑定事件监听器或设置初始样式:

javascript

Vue.directive('highlight', {
  bind: function (el, binding) {
    // 根据binding.value设置元素背景色
    el.style.backgroundColor = binding.value;
  }
});

<template>
  <p v-highlight="'yellow'">这段文字会被高亮</p>
</template>

5.2 inserted 钩子

inserted钩子在被绑定元素插入父节点时调用(仅保证父节点存在,但不一定已插入文档)。它常用于依赖 DOM 的操作,如获取元素尺寸:

javascript

Vue.directive('resize', {
  inserted: function (el) {
    function handleResize() {
      console.log(`元素宽度: ${el.offsetWidth}, 高度: ${el.offsetHeight}`);
    }
    window.addEventListener('resize', handleResize);
    el.__handleResize = handleResize; // 存储回调函数以在unbind时移除
  },
  unbind: function (el) {
    window.removeEventListener('resize', el.__handleResize);
  }
});

<template>
  <div v-resize style="width: 200px; height: 100px; background-color: lightblue;"></div>
</template>

5.3 update 钩子

update钩子在组件更新时调用,此时元素的父节点可能尚未更新。它用于响应数据变化并更新 DOM:

javascript

Vue.directive('text-color', {
  update: function (el, binding) {
    el.style.color = binding.value;
  }
});

<template>
  <p v-text-color="textColor">这段文字颜色会随数据变化</p>
  <button @click="textColor ='red'">变红</button>
  <button @click="textColor = 'blue'">变蓝</button>
</template>

<script>
export default {
  data() {
    return {
      textColor: 'black'
    };
  }
};
</script>

5.4 componentUpdated 钩子

componentUpdated钩子在组件及其子组件的 VNode 全部更新后调用。它确保 DOM 已经完成更新,适合进行依赖完整 DOM 状态的操作:

javascript

Vue.directive('scroll-to-bottom', {
  componentUpdated: function (el) {
    el.scrollTop = el.scrollHeight;
  }
});

<template>
  <div v-scroll-to-bottom style="height: 200px; overflow-y: scroll;">
    <p v-for="item in list" :key="item">{{ item }}</p>
  </div>
</template>

<script>
export default {
  data() {
    return {
      list: Array.from({ length: 20 }, (_, i) => `Item ${i + 1}`)
    };
  }
};
</script>

5.5 unbind 钩子

unbind钩子在指令与元素解绑时调用,仅会执行一次。它用于清理资源,如移除事件监听器:

javascript

Vue.directive('click-outside', {
  bind: function (el, binding) {
    function handleClick(event) {
      if (!el.contains(event.target)) {
        binding.value(); // 调用指令绑定的回调函数
      }
    }
    document.addEventListener('click', handleClick);
    el.__handleClick = handleClick;
  },
  unbind: function (el) {
    document.removeEventListener('click', el.__handleClick);
  }
});

<template>
  <div v-click-outside="closeDropdown">
    <button>下拉菜单</button>
    <div v-show="isOpen">菜单内容</div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      isOpen: false
    };
  },
  methods: {
    closeDropdown() {
      this.isOpen = false;
    }
  }
};
</script>

六、指令的参数与修饰符

6.1 指令参数(Argument)

指令参数用于指定指令的具体操作目标。例如,v-bind:href中的href是参数,表示绑定href属性;v-on:click中的click是参数,表示绑定点击事件:

javascript

<template>
  <a v-bind:href="url">点击跳转</a>
  <button v-on:click="handleClick">点击按钮</button>
</template>

<script>
export default {
  data() {
    return {
      url: 'https://www.example.com'
    };
  },
  methods: {
    handleClick() {
      console.log('按钮被点击');
    }
  }
};
</script>

6.2 指令修饰符(Modifier)

指令修饰符用于调整指令的行为。例如,v-on:click.prevent中的.prevent修饰符用于阻止默认事件(如表单提交):

javascript

<template>
  <form v-on:submit.prevent="handleSubmit">
    <input type="text" />
    <button type="submit">提交</button>
  </form>
</template>

<script>
export default {
  methods: {
    handleSubmit() {
      console.log('表单提交被拦截,执行自定义逻辑');
    }
  }
};
</script>

Vue 内置指令支持多种修饰符,如.stop(阻止事件冒泡)、.once(事件仅触发一次)等。自定义指令也可以通过解析修饰符实现灵活的逻辑:

javascript

Vue.directive('delay-click', {
  bind: function (el, binding) {
    const delay = binding.modifiers.delay || 1000; // 默认延迟1秒
    el.addEventListener('click', function () {
      setTimeout(() => {
        binding.value();
      }, delay);
    });
  }
});

<template>
  <button v-delay-click.delay="500" @click="delayedAction">延迟点击</button>
</template>

<script>
export default {
  methods: {
    delayedAction() {
      console.log('延迟执行的操作');
    }
  }
};
</script>

七、指令与组件的交互

7.1 指令访问组件实例

在指令的钩子函数中,可以通过vnode.context访问当前组件实例。这使得指令能够获取组件的数据或调用组件的方法:

javascript

Vue.directive('log-data', {
  bind: function (el, binding, vnode) {
    const componentInstance = vnode.context;
    console

javascript

Vue.directive('log-data', {
  bind: function (el, binding, vnode) {
    const componentInstance = vnode.context;
    console.log('组件实例中的数据:', componentInstance.someData);
    // 调用组件实例的方法
    componentInstance.someMethod(); 
  }
});

<template>
  <div v-log-data>指令访问组件数据和方法</div>
</template>

<script>
export default {
  data() {
    return {
      someData: '示例数据'
    };
  },
  methods: {
    someMethod() {
      console.log('组件方法被指令调用');
    }
  }
};
</script>

上述代码中,log-data自定义指令在bind钩子中,通过vnode.context获取到组件实例,进而访问组件的数据someData 并调用组件的方法someMethod 。

7.2 指令向组件传递数据

指令也可以通过绑定值向组件传递数据。例如,创建一个指令用于动态修改组件的某个属性:

javascript

Vue.directive('update-component-prop', {
  update: function (el, binding, vnode) {
    const componentInstance = vnode.componentInstance;
    if (componentInstance) {
      // 假设组件有一个prop名为targetProp
      componentInstance.$props.targetProp = binding.value; 
    }
  }
});

<template>
  <my-component v-update-component-prop="dynamicValue"></my-component>
</template>

<script>
import MyComponent from './MyComponent.vue';

export default {
  components: {
    MyComponent
  },
  data() {
    return {
      dynamicValue: '初始值'
    };
  }
};
</script>

// MyComponent.vue
<template>
  <div>组件属性值: {{ targetProp }}</div>
</template>

<script>
export default {
  props: {
    targetProp: {
      type: String,
      default: ''
    }
  }
};
</script>

在这个例子中,update-component-prop指令在update钩子中,根据binding.value更新了子组件MyComponenttargetProp属性 。

7.3 指令与组件生命周期的协同

指令的生命周期钩子可以与组件的生命周期结合使用,实现更复杂的逻辑。比如,在组件挂载完成后执行指令的特定操作:

javascript

Vue.directive('after-mount-action', {
  inserted: function (el, binding, vnode) {
    const componentInstance = vnode.context;
    componentInstance.$nextTick(() => {
      // 确保组件及其子元素都已挂载完成
      console.log('组件挂载完成后,指令执行额外操作'); 
      // 可以在这里执行依赖完整DOM结构的操作
    });
  }
});

<template>
  <div v-after-mount-action>指令与组件生命周期协同</div>
</template>

<script>
export default {
  // 组件逻辑
};
</script>

这里after-mount-action指令利用inserted钩子,并结合组件的$nextTick方法 ,确保在组件及其子元素完全挂载到 DOM 后执行相应操作。

八、指令的性能优化

8.1 减少不必要的指令调用

在使用指令时,应避免在频繁更新的数据上使用复杂指令,防止大量的 DOM 操作和重新渲染。例如,对于一个每秒更新多次的计数器,如果使用v-text指令实时显示数值,会导致频繁的 DOM 更新:

html

<!-- 不推荐:频繁更新导致性能损耗 -->
<div v-text="counter"></div>

<script>
export default {
  data() {
    return {
      counter: 0
    };
  },
  mounted() {
    setInterval(() => {
      this.counter++;
    }, 1000);
  }
};
</script>

更优的做法是使用计算属性,仅在数据真正变化时触发更新:

html

<!-- 推荐:减少不必要的更新 -->
<div>{{ formattedCounter }}</div>

<script>
export default {
  data() {
    return {
      counter: 0
    };
  },
  computed: {
    formattedCounter() {
      return this.counter;
    }
  },
  mounted() {
    setInterval(() => {
      this.counter++;
    }, 1000);
  }
};
</script>

8.2 缓存指令相关数据

对于需要重复计算的指令逻辑,可以缓存结果以减少计算开销。例如,自定义一个指令用于计算元素的某个复杂样式值:

javascript

Vue.directive('complex-style', {
  bind: function (el) {
    // 缓存计算结果
    el.__complexStyleCache = calculateComplexStyle(); 
  },
  update: function (el) {
    const style = el.__complexStyleCache;
    // 使用缓存结果更新样式
    applyStyle(el, style); 
  }
});

function calculateComplexStyle() {
  // 模拟复杂计算
  return {
    color:'red',
    fontSize: '16px',
    // 更多复杂计算得到的样式属性
  };
}

function applyStyle(el, style) {
  for (const prop in style) {
    el.style[prop] = style[prop];
  }
}

上述代码中,complex-style指令在bind钩子中计算复杂样式值并缓存 ,在update钩子中直接使用缓存结果更新元素样式,避免了重复计算。

8.3 批量处理指令更新

当多个指令需要同时更新时,可以将它们的更新逻辑合并,减少 DOM 操作次数。例如,有两个指令分别控制元素的颜色和字体大小:

javascript

Vue.directive('color-directive', {
  update: function (el, binding) {
    el.style.color = binding.value;
  }
});

Vue.directive('font-size-directive', {
  update: function (el, binding) {
    el.style.fontSize = binding.value;
  }
});

// 优化方案:合并为一个指令
Vue.directive('combined-style', {
  update: function (el, binding) {
    const { color, fontSize } = binding.value;
    el.style.color = color;
    el.style.fontSize = fontSize;
  }
});

通过将两个指令的功能合并为combined-style指令 ,在更新时可以一次完成多个样式属性的修改,减少 DOM 操作次数,提升性能。

九、指令的边界情况与常见问题

9.1 指令与动态组件的兼容性

当指令应用于动态组件时,需要注意指令的生命周期钩子执行时机。例如,使用v-if控制动态组件的显示与隐藏:

html

<component :is="currentComponent" v-my-directive></component>

<script>
export default {
  data() {
    return {
      currentComponent: 'ComponentA'
    };
  }
};
</script>

在这种情况下,当currentComponent的值发生变化时,指令的unbind钩子会在旧组件卸载时触发,bind钩子会在新组件挂载时触发。开发者需要确保指令逻辑在组件切换时的正确性,避免出现资源未释放或初始化错误的问题。

9.2 指令修饰符的优先级问题

当一个指令同时使用多个修饰符时,可能会出现优先级冲突。例如:

html

<button v-on:click.prevent.stop="handleClick">按钮</button>

这里.prevent(阻止默认事件)和.stop(阻止事件冒泡)的执行顺序可能影响最终效果。在 Vue 中,修饰符的执行顺序按照定义的顺序进行,但开发者仍需谨慎处理,避免逻辑错误。

9.3 指令在服务端渲染(SSR)中的表现

在服务端渲染场景下,指令的行为可能与客户端不同。例如,依赖 DOM 操作的指令(如v-show、自定义的 DOM 事件指令)在服务端无法生效,因为服务端没有真实的 DOM 环境。开发者需要针对 SSR 场景进行特殊处理,比如使用v-if替代v-show ,或者使用条件判断来区分服务端和客户端的逻辑:

html

<div v-if="isClient">
  <!-- 仅在客户端执行的指令 -->
  <button v-my-client-only-directive>客户端指令</button> 
</div>

<script>
export default {
  data() {
    return {
      isClient: typeof window!== 'undefined'
    };
  }
};
</script>

十、总结与展望

10.1 总结

通过对 Vue 指令模块的深入分析,我们从指令的注册机制、模板编译过程、生命周期钩子、参数与修饰符、组件交互、性能优化以及边界问题等多个维度进行了源码级解析。指令作为 Vue 中连接数据与 DOM 的重要桥梁,不仅提供了丰富的内置功能,还允许开发者通过自定义指令扩展其能力。理解指令的运行原理,有助于开发者写出更高效、灵活的代码,避免常见的性能问题和逻辑错误。

10.2 展望

随着 Vue 3 的不断发展和生态完善,指令模块可能会迎来更多优化和创新。例如:

  • 更强大的内置指令:未来可能会新增更多实用的内置指令,进一步简化常见业务场景的开发(如数据可视化指令、复杂动画指令)。

  • 指令与 Composition API 的深度融合:在 Vue 3 的 Composition API 中,指令可能会提供更便捷的集成方式,允许开发者在组合函数中更灵活地使用指令逻辑。

  • 性能优化与智能分析:通过静态分析和编译器优化,Vue 可能会自动识别低效的指令使用方式,并给出优化建议,帮助开发者提升应用性能。

  • 跨端指令支持:随着 Vue 在移动端、桌面端等多端场景的应用扩展,指令可能会增加对不同平台的适配能力,实现一次编写、多端运行。

总之,Vue 指令模块作为框架的核心特性之一,将持续在开发者的日常工作中发挥重要作用,并在未来的技术演进中不断焕发出新的活力。

JavaScript 中字符串转字符数组的两种优雅方式

作者 天天扭码
2025年4月18日 12:32

在 JavaScript 的编程世界里,字符串和数组是极为常见的数据类型。在实际开发中,我们常常会遇到需要将字符串转换为字符数组的情况,比如对字符串的每个字符进行单独处理、对字符进行排序等。接下来,我们将深入探讨如何使用 JavaScript 实现这一转换,同时结合代码示例详细分析不同方法的特点。

方法一:使用 split() 方法

代码示例

let str = 'hello';
// 字符串API
let arr = str.split('');
console.log(arr);

详细解释

split() 是 JavaScript 字符串对象的一个内置方法,它的主要作用是依据指定的分隔符将字符串分割成多个子字符串,然后把这些子字符串存到一个数组中。当我们把分隔符设定为空字符串 '' 时,就可以把字符串的每个字符都拆分成数组里的一个元素。

代码运行结果

运行上述代码,控制台会输出 ['h', 'e', 'l', 'l', 'o'],这表明字符串 'hello' 成功地被转换为了一个包含每个字符的数组。

方法二:使用 ES6 展开运算符

代码示例

let str = 'hello';
// es6 展开运算符 
// ... 展开运算符 spread
// es6一大特性,让代码简洁,优雅
console.log([...str]);

详细解释

ES6 引入的展开运算符 ... 是一个非常强大的特性,它能够把可迭代对象(像字符串、数组之类的)展开成一个个独立的元素。对于字符串而言,运用展开运算符就能把它的每个字符都展开为数组的一个元素。

代码运行结果

运行这段代码,控制台同样会输出 ['h', 'e', 'l', 'l', 'o'],实现了和 split() 方法相同的字符串转字符数组的功能。

两种方法的对比

代码简洁性

从代码简洁性的角度来看,展开运算符明显更胜一筹。它只用了一行代码 [...str] 就完成了字符串到字符数组的转换,而 split() 方法还需要额外定义一个变量来存储转换后的数组。

可读性和优雅性

展开运算符让代码更加直观,一看就明白是要把字符串展开成数组元素,增强了代码的可读性和优雅性。特别是在处理复杂逻辑时,简洁的代码能让开发者更快地理解代码意图。

性能方面

在大多数情况下,这两种方法的性能差异并不明显。但是,在处理大规模字符串时,展开运算符可能会稍微快一些,因为它直接利用了 JavaScript 的底层机制

总结

在 JavaScript 中,split() 方法和展开运算符都能有效地将字符串转换为字符数组。如果你追求代码的简洁性和优雅性,那么展开运算符是更好的选择;如果你习惯使用传统的字符串方法,split() 也能很好地完成任务。根据具体的项目需求和个人编程习惯,灵活运用这两种方法,能让你的代码更加出色。

希望这篇博客能帮助你更好地理解和运用这两种字符串转字符数组的方法。如果你在实践过程中有任何疑问,欢迎留言交流。

Module Federation v0.12新特征详解

作者 KenXu
2025年4月18日 11:35

功能(增强):支持共享模块层

概述

此功能在 @module-federation/enhanced 包中引入了共享模块层支持。Module Federation允许多个JavaScript应用在运行时动态共享代码。共享模块层通过分层或分组方式管理共享依赖,提升了模块组织的灵活性。

详细分析

  • 共享模块层是什么?

    • 允许开发者定义共享模块的层级(如全局、应用特定或功能特定),例如共享react或自定义工具库。
    • 提供细粒度控制,优化依赖共享,减少重复模块实例。
  • 关键优势

    • 减少打包体积:通过分层共享,Webpack优化树摇(tree-shaking),避免不必要代码。
    • 版本管理:支持版本化依赖,减少版本冲突。
    • 提升开发体验:清晰的层级划分,便于扩展大型单体仓库或微前端架构。
  • 用例示例

    • 在一个单体仓库中,主机应用和多个远程应用需要共享react(全局)和特定工具库(仅限某些远程)。配置示例:
import { createModuleFederationConfig } from '@module-federation/enhanced';

export default createModuleFederationConfig({
  shared: {
    react: { singleton: true, version: '18.3.1' }, // 全局层
    'custom-utils': { singleton: false, layer: 'feature-specific' }, // 功能特定层
  },
});

确保react全局共享,custom-utils仅在特定层共享,减少冲突。

  • 技术考量

    • 配置:可能在shared属性中新增layer选项,定义共享范围。
    • 局限性:需谨慎规划层级,避免单例模块导致的状态问题。
    • 适用场景:适合Nx等单体仓库,优化大型微前端项目。

2. 功能(dts-plugin):支持排除、提取、指定第三方DTS

概述

此功能增强了@module-federation/dts-plugin,用于生成Module Federation模块的TypeScript声明文件(.d.ts)。新增支持排除提取指定第三方.d.ts文件,提升类型安全和开发体验。

详细分析

  • 功能内容

    • 排除:从生成的.d.ts文件中排除不需要的第三方类型。
    • 提取:将第三方类型提取到单独文件,确保输出精简。
    • 指定:允许明确定义第三方.d.ts文件的位置或内容。
  • 关键优势

    • 提升类型安全:排除无关类型,生成更精确的.d.ts,减少类型错误。
    • 优化模块集成:确保主机和远程模块的类型一致,适配复杂依赖关系。
    • 灵活性:开发者可自定义类型生成,适配项目需求。
  • 用例示例

    • 远程应用暴露React组件,主机应用消费。远程使用ts-essentials库,但无需将其类型包含在.d.ts中。配置示例:
import { createModuleFederationConfig } from '@module-federation/enhanced';

export default createModuleFederationConfig({
  dts: {
    generateTypes: {
      extractThirdParty: false, // 排除第三方类型
      typesFolder: 'custom-types', // 指定输出目录
    },
    consumeTypes: {
      remoteTypeUrls: async () => ({
        remote1: {
          alias: 'remote1-alias',
          api: 'http://localhost:8081/custom-dir/@mf-types.d.ts',
        },
      }),
    },
  },
});

确保生成的类型精简,主机正确解析类型。

  • 技术细节

    • 配置选项:新增extractThirdParty和exclude等选项,控制类型生成。例如:
      interface DtsRemoteOptions {
        extractThirdParty?: boolean; // 提取第三方类型
        exclude?: string[]; // 排除特定类型文件
        typesFolder?: string; // 输出目录
      }
      
    • 错误处理:结合abortOnError和maxRetries处理类型解析失败。
    • CI/CD集成:解决生产环境中类型获取限制,支持流水线验证。
  • 挑战与注意事项

    • 配置复杂性:需理解排除或提取的影响,避免遗漏关键类型。
    • 性能开销:提取第三方类型可能增加构建时间。
    • 错误排查:需正确设置tsConfigPath和typesFolder,避免文件缺失错误。

总结

  • 共享模块层 (#3276):优化依赖管理,减少打包体积,提升微前端项目的可扩展性,适合大型单体仓库。
  • DTS插件增强 (#3692):提供灵活的类型生成控制,生成精简且准确的.d.ts文件,改善TypeScript用户体验。

数字孪生-DTS-孪创城市-低空范围

2025年4月18日 11:31

前言

今天蘑菇头带来的是低空范围的功能实现分享,主要是当点击低空范围时会显示出所有的图层,包括限飞范围,申请范围和无人机平台,并且我们还做了一个图层控制的按钮,可以很方便的控制着三个图层的显示和隐藏,并且还做了相应的图例,快来看看是如何实现的吧。

image-20250418104205432.png

思路

首先还是老样子,需要调整视角,设置暗黑模式这里我就不赘述了,直接看代码。

import { useDigitalTwinStore } from '@/stores/digitalTwin'
import { getForbidFlyRegion, getLimitFlyRegion, getApplyCoverage, getPlatformData } from '@/api/home'
const digitalTwinStore = useDigitalTwinStore()

const cameraParam = [554876.14875, 3386383.881719, 104995.88, -54.943035, -89.999977, 2]
const hideLayer = ['手工模型', 'cim2建筑', '苏州地形影像_Water']
const setStyles = async () => {
    //样式调整:a.打开黑暗模式  b.隐藏手工模型  c.调整地形影像的亮度
    await fdapi.camera.set(cameraParam)
    await fdapi.weather.setDarkMode(true)
    await fdapi.infoTree.hide(hideLayer.map(name => digitalTwinStore.digitalTwin[name]))
    await fdapi.tileLayer.setStyle(digitalTwinStore.digitalTwin['大地形影像'], null, null, null, 0.1)
    await fdapi.tileLayer.setStyle(digitalTwinStore.digitalTwin['苏州地形影像_Terrain'], null, null, null, 0.5)
}

然后我们分三步添加图层,分别是限飞范围,申请范围和无人机平台。

第一步添加限飞范围,限飞范围包括禁飞区和限飞区,首先我们先获取到这些区域的数据。在api下的home提供了获取这些geojson数据的接口,拿到这些数据后通过geoJSONLayer这个类进行绘制。由于返回的数据里面没有高度字段,所以我们手动添加一下,然后调整一下颜色透明度等等。

let geoJSONLayerIds: string[] = [];
// 添加禁飞区
const addForbidFlyRegion = async () => {
    const { data } = await getForbidFlyRegion()
    const feature = data.features[0]
    feature.properties.height = 3000
    console.log(data,'data');
    
    //分类渲染器
    let classBreaksRenderer = {
        //材质样式
        style: 16,
        //默认符号化配置
        defaultSymbol: {
            //符号化类型枚举:0 simple-marker圆形点填充  1 simple-line线填充  2 simple-fill面填充 3 polygon3d填充
            symbolType: 2,
            //填充色
            color: [1, 0, 0, 0.5],
            //轮廓线
            outline: {
                //线宽
                width: 1,
                //颜色
                color: [1, 0, 0, 1]
            }
        },
        //按field高度属性拉高面
        visualVariables: [
            {
                //控制可视化显示的类型:高度
                type: VisualType.Height,
                //属性字段名称
                field: 'height',
                //属性字段类型
                fieldType: FieldType.Number
            }
        ]
    }

    //用分类渲染器添加GeoJSONLayer
    fdapi.geoJSONLayer.add({
        id: 'forbidFlyRegion_geojson',
        visible: true, //加载后是否显示
        rotation: [0, 0, 0], //图层旋转
        offset: [0, 0, 10], //基于原始位置的偏移量
        needProject: false, //开启投影转换
        onTerrain: false, //是否贴地
        collision: true, //开启碰撞
        sourceJson: data,
        renderer: classBreaksRenderer
    })
    geoJSONLayerIds.push('forbidFlyRegion_geojson');
}
// 添加限飞区
const addLimitFlyRegion = async () => {
    const { data } = await getLimitFlyRegion()
    const feature = data.features[0]
    feature.properties.height = 120
    //分类渲染器
    let classBreaksRenderer = {
        //材质样式
        style: 16,
        //默认符号化配置
        defaultSymbol: {
            //符号化类型枚举:0 simple-marker圆形点填充  1 simple-line线填充  2 simple-fill面填充 3 polygon3d填充
            symbolType: 2,
            //填充色
            color: [0, 0, 0, 0.3],
            //轮廓线
            outline: {
                //线宽
                width: 1,
                //颜色
                color: [0.5, 0.5, 0.5, 1]
            }
        },
        //按field高度属性拉高面
        visualVariables: [
            {
                //控制可视化显示的类型:高度
                type: VisualType.Height,
                //属性字段名称
                field: 'height',
                //属性字段类型
                fieldType: FieldType.Number
            }
        ]
    }
    //用分类渲染器添加GeoJSONLayer
    fdapi.geoJSONLayer.add({
        id: 'addLimitFlyRegion_geojson',
        visible: true, //加载后是否显示
        rotation: [0, 0, 0], //图层旋转
        offset: [0, 0, 10], //基于原始位置的偏移量
        needProject: false, //开启投影转换
        onTerrain: false, //是否贴地
        collision: true, //开启碰撞
        sourceJson: data,
        renderer: classBreaksRenderer
    })
    geoJSONLayerIds.push('addLimitFlyRegion_geojson');
}

image-20250418110911508.png

第二步添加申请范围,我们通过polygon来进行添加。

const polygonIds: string[] = []
// 添加申请范围
const addApplyCoverage = async () => {
    const { data } = await getApplyCoverage();
    const feature = data.features[0]
    const coordinates = feature.geometry.coordinates
    const polygonList: any[] = []
    coordinates.forEach((c: any, index: number) => {
        let p1 = {
            id: 'applyCoverage_' + index,
            coordinates: c,
            coordinateType: 0, //坐标系类型,取值范围:0为Projection类型,1为WGS84类型,2为火星坐标系(GCJ02),3为百度坐标系(BD09),默认值:0
            range: [0, 1000000], //可视范围:[近裁距离, 远裁距离],取值范围: [任意负值, 任意正值]
            color: [19 / 255, 103 / 255, 136 / 255, 0.5], //多边形的填充颜色
            frameColor: [0, 195 / 255, 255 / 255, 1],
            frameThickness: 200, //边框厚度
            intensity: 2, //亮度
            style: 0, //单色 请参照API开发文档选取枚举
            depthTest: false, //是否做深度检测 开启后会被地形高度遮挡
            priority: 1 //显示优先级 值越大显示越靠上
        }
        polygonList.push(p1)
        polygonIds.push(p1.id)
    })
    fdapi.polygon.add(polygonList)
}

image-20250418111113264.png

第三步,添加无人机点位,我们通过marker添加。

// 添加无人机平台
const addPlatformLayer = async () => {
    const { data } = await getPlatformData()
    const markerList: any = []
    data.features.forEach((item: any, index: number) => {
        const coordinate = item.geometry.coordinates
        const size = 0.8
        let o1 = {
            id: 'platform_marker_' + index,
            groupId: 'platform_marker',
            coordinate: coordinate, //坐标位置
            coordinateType: 0, //默认0是投影坐标系,也可以设置为经纬度空间坐标系值为1
            anchors: [(-56 / 2) * size, 72 * size], //锚点,设置Marker的整体偏移,取值规则和imageSize设置的宽高有关,图片的左上角会对准标注点的坐标位置。示例设置规则:x=-imageSize.width/2,y=imageSize.height
            imageSize: [56 * size, 72 * size], //图片的尺寸
            range: [1, 10000000], //可视范围
            imagePath: '@path:无人机.png', //显示图片路径

            autoHidePopupWindow: true, //失去焦点后是否自动关闭弹出窗口
            autoHeight: false, // 自动判断下方是否有物体
            displayMode: 1, //智能显示模式  开发过程中请根据业务需求判断使用四种显示模式
            clusterByImage: true, // 聚合时是否根据图片路径分类,即当多个marker的imagePath路径参数相同时按路径对marker分类聚合
            priority: 0, //避让优先级
            occlusionCull: false //是否参与遮挡剔除
        }
        markerList.push(o1)
    })
    fdapi.marker.add(markerList)
}

image-20250418111223340.png

OK,添加完这三个图层之后就是通过按钮来控制这些图层的显示和隐藏了。首先我们找到这些按钮的位置,在children下的scope.vue文件下,然后添加点击事件,在点击事件中来控制图层的显示和隐藏。我们在刚刚的init文件夹下再抛出三个方法来控制图层。

//  切换显示隐藏-限飞范围
export const changeRestrictFlyLayer = async (show: boolean) => {
    if (show) {
        fdapi.geoJSONLayer.show(geoJSONLayerIds)
    } else {
        fdapi.geoJSONLayer.hide(geoJSONLayerIds)
    }
}
//  切换显示隐藏-申请范围
export const changeApplyCoverage = async (show: boolean) => {
    if (show) {
        fdapi.polygon.show(polygonIds)
    } else {
        fdapi.polygon.hide(polygonIds)
    }
}
//  切换显示隐藏-无人机平台
export const changePlatformLayer = async (show: boolean) => {
    if (show) {
        fdapi.marker.showByGroupId('platform_marker')
    } else {
        fdapi.marker.hideByGroupId('platform_marker')
    }
}

然后在scope中使用这些方法即可。

最后当组件卸载前需要做一些删除清理操作

export const exitLowAltitudeRange = async () => {
    await fdapi.weather.setDarkMode(false)
    await fdapi.infoTree.show(hideLayer.map(name => digitalTwinStore.digitalTwin[name]))
    await fdapi.tileLayer.setStyle(digitalTwinStore.digitalTwin['大地形影像'], null, null, null, 0.3)
    await fdapi.tileLayer.setStyle(digitalTwinStore.digitalTwin['苏州地形影像_Terrain'], null, null, null, 1)

    fdapi.geoJSONLayer.delete(geoJSONLayerIds)
    fdapi.polygon.delete(polygonIds)
    fdapi.marker.deleteByGroupId('platform_marker')
}

涉及的飞渡api

  • 通过geoJSONLayer添加面-fdapi.geoJSONLayer.add
  • 显示geoJSONLayer-fdapi.geoJSONLayer.show(geoJSONLayerIds)
  • 隐藏geoJSONLayer-fdapi.geoJSONLayer.hide(geoJSONLayerIds)
  • 通过polygon添加面-fdapi.polygon.add()
  • 显示polygon-fdapi.polygon.show(polygonIds)
  • 隐藏polygon-fdapi.polygon.hide(polygonIds)
  • 添加点-fdapi.marker.add()
  • 显示点-fdapi.marker.showByGroupId('platform_marker')
  • 隐藏点-fdapi.marker.hideByGroupId('platform_marker')

【学习笔记】从mobx迁移到redux时的概念映射

作者 avocado_green
2025年4月18日 11:27

背景

上一份工作用了很久的mobx,对于它简洁的API、轻量级的特性非常欣赏。新的工作需要转为使用redux,搜了一下redux官方文档,没有看到migrate from mobx的文章,社区也很少有从mobx迁移到redux的介绍,于是自己边学习边记录一下二者的共通之处与不同点,也为后续需要的同学提供参考。

核心概念映射

concepts table

概念 mobx redux
状态 observable state store
触发状态改变 action action, dispatch
状态更新逻辑 - reducer
派生值 computed value selector
副作用 reaction -

mobx 概念流程图

在这里插入图片描述

redux 概念流程图

在这里插入图片描述

概念详解

observable state vs. store

在mobx中,要想某个变量可以被其他地方自动追踪变化,需要将该变量标记为observable,标记的方法按照变量的类型有类装饰器、属性装饰器、函数几种。读取变量的值非常方便,当做普通值使用即可。详见官方文档中的用法。 在redux中,“可观察变量”的概念是被弱化的,即所有利用redux定义的变量都被写入一个全局的store变量中。如果需要获取store中的某个属性的话,需要使用 store.getState() 来读取。

action vs. dispatch

在mobx中,action的写法是非常自由的,通常为一个接收新值的函数,内部将旧值替换为新值,类似于react的state hook中的setter。对变量改动范围仅限于这个局部属性,接下来受影响的派生值和副作用也仅限于与之相关的局部属性。可以认为它的action是没有boilerplate code的,非常简洁透明,而且影响范围也非常局部。 在redux中,action的写法是非常固定的,格式如下

const addTodoAction = {
  type: 'todos/todoAdded',
  payload: 'Buy milk'
}

个人认为这样写是为了方便reducer消费。相比mobx,boilerplate code比较明显。

[redux独有] reducer

个人认为reducer是redux的核心,它定义了数据更新的所有逻辑,其他的概念如state, action 都是为了reducer服务的。由于要适配action的boilerplate code,一个常见的reducer通常是如下格式

// store
const initialState = { value: 0 }

function counterReducer(state = initialState, action) {
  // action对号入座,这里也常常使用swtich-case语句首先
  if (action.type === 'counter/incremented') {
    return {
    // 复制原始值,这一步必不可少,否则整体state的其他变量将受影响
      ...state,
      // 更新需要更新的值
      value: state.value + 1
    }
  }
  // 非已知的action,返回原始值
  return state
}

computed value vs. selectors

在mobx中,computed value可以视为和vue的计算属性是等同的概念。mobx官网的第一篇文章中有这样一句话,可见它所推崇的是尽可能使用自动计算的派生值。它认为大部分变量应该像Excel表格中的计算公式单元格一样自动更新。有了计算属性,派生值的更新变得非常的清晰和便捷。

Anything that can be derived from the application state, should be. Automatically.

可以使用装饰器或者makeAutoObservable来将变量标注为计算属性,详见官方文档。一个class写法的示例如下

import { makeObservable, observable } from "mobx"

class OrderLine {
    price = 0
    amount = 1
    
    constructor(price) {
    // 这个函数使得属性自动变成observable, getter自动变成计算属性
        makeAutoObservable(this)
        this.price = price
    }

// 计算属性
    get total() {
        console.log("Computing...")
        return this.price * this.amount
    }
}

在redux中,selector的直接定义是从store中读取部分state的值,但是我们完全可以把它当做书写计算属性的逻辑的地方,示例如下

// 定义selector
const selectTotalCompletedTodos = state => {
  const completedTodos = state.todos.filter(todo => todo.completed)
  return completedTodos.length
}

// 组件内
const completedTodos = useSelector(selectTotalCompletedTodos)

虽然二者能实现同样的功能,但是在性能上是存在差距的,这里引用DeepSeek的回答

虽然两者都能实现派生状态管理,但 Redux selectors 需要显式优化才能达到 MobX computed 的自动化水平。选择应基于:项目规模、团队习惯、对不可变数据的需求程度。大型复杂应用推荐 Redux+Reselect+RTK Query,快速迭代项目更适合 MobX 的响应式范式。

[mobx独有] reaction

个人认为mobx的reaction可以起到将react的useEffect提到组件外面执行的作用,从而将副作用单独管理,避免受组件渲染周期的影响。具体的写法有autorun reactionwhen 三种,详见官方文档

总结

本文介绍了mobx和redux在概念上共通的部分,并且比较了写法和设计上的差异。当然,这两个库的使用方法和原理以及性能还有很多可以比较的内容,待后续对redux有了更多的实践再进行记录。

版权声明

本文为博主原创文章,首发于CSDN,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。 原文链接:blog.csdn.net/nameofacity…

前端微应用-乾坤(qiankun)原理分析-沙箱隔离(css)

作者 三原
2025年4月18日 11:20

为什么需要沙箱隔离?沙箱sandbox又是什么概念呢?

沙箱(sandbox)是前端开发中的一个概念,它指的是在某个特定的环境中,对代码进行隔离,防止代码相互影响。在 Web 开发中,沙箱通常指的是在某个特定的 DOM 节点中,对代码进行隔离,防止代码相互影响。

比如网络安全中的沙箱是指,一些资源文件在沙箱环境中进行分析,避免是恶意脚本被执行了影响整个系统瘫痪。

Q: 为什么乾坤需要沙箱隔离呢?我之前开发多页应用的时候也没用到该技术啊?只是配置了下nginx转发到对应的HTML就行了啊?

A: 因为乾坤是容器(主应用),以及子应用的模式,载体主应用是一个完整的HTML,子应用仅作为HTML中的一部分。如下图所示:

6.png

如果这种模式下,子应用里面有样式对body、html、:root这种全局样式在会照成影响的,以及如果子应用中重写了window某个方法,那么岂不是对全局都污染了。

如果你说你们比较规范不会有以上这些问题,但是你不能保证你引入的第三方插件库有没有这种操作,所以才需要沙箱隔离。

Q: 沙箱隔离是运行时,还是编译时,会不会影响我的性能?

A: 沙箱隔离是运行时的,多少会影响点性能,如果你是后台管理那么可以忽略不计,如果你是做3d或者2d图谱流程图等里面需要频繁用到检测节点使用Math计算的话(因为节点碰撞可能是要千万级的使用window上的Math方法),那么可能需要你关闭沙箱隔离

Q: 那我应该什么场景下关闭沙箱,需要我自己测试下性能吗?

A: 如果你频繁需要用到window上的API,比如1s调用10万次,那么你必须要去关闭沙箱。经测试1s内调用1万次基本无感的。正常是不会影响你开发的。

Q: 我怎么关闭沙箱?或者我能用什么方式解决这个问题呢?

A: start({sandbox: false })就可以关闭了。比如你知道频繁调用了Math方法,那么你把Math方法cloneDeep一份再去使用就不会有这个问题了。

Q: 我能只关闭单独某个微应用的沙箱吗?
A: 暂时看应该是不行的!

在乾坤中针对css,js的window分别做了沙箱隔离,咱们接下来会一个个的拆分,看看它是怎么做到的。

css 沙箱隔离

乾坤(qiankun) 默认是没有对css沙箱隔离的。但是它提供了一个API可以帮咱们开启css隔离,其实就是把全局的body、html、:root进行了替换,如果你自己设置一个scope避免这些全局样式同样相当于隔离了。start({ sandbox: {experimentalStyleIsolation: true} });

在上一篇中咱们可以看到子应用的htmllink标签外部引入的css被插件import-html-entry替换成了style标签。

比如是<link rel="stylesheet" href="./test.css" />

/* ./test.css */
a {
 color: red;
}

会被转成<style>a{color: red;}</style>

转换后的HTML是怎么操作css的呢?通过以下代码

function createElement(
  appContent: string, // 1. 替换后的HTML
  scopedCSS: boolean,
  appName: string,
): HTMLElement {
  const containerElement = document.createElement('div');
  containerElement.innerHTML = appContent;
  // appContent always wrapped with a singular div
  const appElement = containerElement.firstChild as HTMLElement;

  if (scopedCSS) {
    const attr = appElement.getAttribute(css.QiankunCSSRewriteAttr);
    if (!attr) {
      appElement.setAttribute(css.QiankunCSSRewriteAttr, appName); // 2. 这里对父元素增加了隔离属性data-qiankun="app-vue-history" appName
    }

    const styleNodes = appElement.querySelectorAll('style') || []; // 3. 获取所有style标签
    forEach(styleNodes, (stylesheetElement: HTMLStyleElement) => {
      css.process(appElement!, stylesheetElement, appName);  // 4. 这里是替换的
    });
  }
  return appElement;
}

具体执行流程如下图所示:

7.png

8.png

乾坤(qiankun)process替换部分是怎么做的呢?如下代码:

  // eslint-disable-next-line class-methods-use-this
 const  ruleStyle = (rule: CSSStyleRule, prefix: string) => {
    const rootSelectorRE = /((?:[^\w\-.#]|^)(body|html|:root))/gm;
    const rootCombinationRE = /(html[^\w{[]+)/gm;

    const selector = rule.selectorText.trim();

    let { cssText } = rule;
    // handle html { ... }
    // handle body { ... }
    // handle :root { ... }
    if (selector === 'html' || selector === 'body' || selector === ':root') {
      return cssText.replace(rootSelectorRE, prefix);
    }

    // handle html body { ... }
    // handle html > body { ... }
    if (rootCombinationRE.test(rule.selectorText)) {
      const siblingSelectorRE = /(html[^\w{]+)(\+|~)/gm;

      // since html + body is a non-standard rule for html
      // transformer will ignore it
      if (!siblingSelectorRE.test(rule.selectorText)) {
        cssText = cssText.replace(rootCombinationRE, '');
      }
    }

    // handle grouping selector, a,span,p,div { ... }
    cssText = cssText.replace(/^[\s\S]+{/, (selectors) =>
      selectors.replace(/(^|,\n?)([^,]+)/g, (item, p, s) => {
        // handle div,body,span { ... }
        if (rootSelectorRE.test(item)) {
          return item.replace(rootSelectorRE, (m) => {
            // do not discard valid previous character, such as body,html or *:not(:root)
            const whitePrevChars = [',', '('];

            if (m && whitePrevChars.includes(m[0])) {
              return `${m[0]}${prefix}`;
            }

            // replace root selector with prefix
            return prefix;
          });
        }

        return `${p}${prefix} ${s.replace(/^ */, '')}`;
      }),
    );

    return cssText;
  }
    1. 处理 html { ... } 、 body { ... } 、 :root { ... }
    1. 处理 html body { ... } 、html > body { ... }
    1. 处理 body下 div,body,span { ... }

然后拼接成需要用的css,在通过style.textContent设置style中的样式`。

css 沙箱总结

Q: 乾坤(qiankun) css 沙箱有没有必要?,看起来好像也没啥特殊的不就加个前缀,并且它默认也没有开启啊?

A: 从本次源码中分析,如果咱们开启start({ sandbox: {experimentalStyleIsolation: true} });后才会增加前缀,并且咱们了解到了它的作用替换规则,其实就是对html、body、:root进行了替换。并且css经过子应用的卸载并不会留下痕迹(影响别的应用)。不开启其实也行的

Q: 乾坤(qiankun) css 沙箱那岂不是个笑话?

A: 如果你的子应用没有全局样式,确实用不到这个API,但是也算是给我们敲了个警钟,避免使用这些全局样式。

设置start({ sandbox: {strictStyleIsolation: true} });的话会启用 shadowDOM无界的核心就是这个,等咱们分析无界的时候再细说。

【前端进阶】深入解析 Flexbox 布局中的 flex-shrink 与 gap 兼容性问题

2025年4月18日 11:18

Flexbox 布局已成为现代 Web 开发中不可或缺的技术,但在实际使用中开发者常会遇到 flex-shrink 导致的内容挤压问题和 gap 属性的兼容性挑战。本文将通过代码示例和原理分析,帮助你彻底理解这些问题并提供解决方案。

一、flex-shrink 导致内容挤压问题

1.1 问题现象

当容器空间不足时,Flex 子项可能出现以下异常情况:

  • 文本内容溢出容器
  • 图片或固定尺寸元素被压缩变形
  • 布局出现不可预测的错位
<div class="container">
  <div class="item">Short Text</div>
  <div class="item">Very Long Text Content That Might Be Truncated</div>
  <div class="item">Fixed Width</div>
</div>

<style>
.container {
  display: flex;
  width: 500px;
  border: 1px solid red;
}

.item {
  flex: 1;  /* 等价于 flex: 1 1 0 */
  border: 1px solid #ccc;
}
</style>

1.2 核心原理剖析

Flex 项目的最终尺寸计算公式:

实际尺寸 = 基准尺寸(flex-basis) + 剩余空间分配 - 收缩空间
收缩空间 = (项目收缩比例 × 基准尺寸) / 总收缩权重 × 空间缺口

当 flex-shrink 值为 1 时,所有项目按基准尺寸比例收缩。默认的 flex-basis: auto 会使得项目基于内容宽度计算,可能导致意外收缩。

1.3 解决方案与代码示例

方案一:禁用收缩

.fixed-item {
  flex-shrink: 0;
  min-width: 120px; /* 双重保险 */
}

方案二:智能收缩控制

.container {
  display: flex;
  width: 500px;
}

.item:nth-child(1) {
  flex: 0 1 200px; /* 基准 200px,可收缩 */
}

.item:nth-child(2) {
  flex: 1 0 150px; /* 基准 150px,不收缩 */
}

.item:nth-child(3) {
  flex: 0 0 100px; /* 固定宽度 */
}

方案三:内容保护策略

.protected-content {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 50px; /* 保证最小可读空间 */
}

1.4 避坑指南

  1. 始终设置 min-width/max-width:特别是对图片、表单控件等需要保持可操作性的元素
  2. 谨慎使用 flex: 1:明确指定 flex-grow 和 flex-shrink 值
  3. 响应式考虑:在不同断点测试极端内容情况
  4. 使用开发者工具:Chrome DevTools 的 Flexbox 调试面板可实时观察收缩情况

二、gap 属性兼容性处理

2.1 兼容性现状

  • 支持情况

    • Flexbox 的 gap:Chrome 84+、Firefox 63+、Safari 14.5+
    • 不支持的浏览器:IE11、旧版 Edge、Safari 13-14.4
  • 核心问题

    • 传统 margin 方案会导致边缘存在多余间距
    • 伪类选择器方案影响代码可维护性

2.2 渐进增强方案

方案一:特性检测 + 降级处理

.container {
  display: flex;
  margin: -5px; /* 抵消边缘间距 */
}

.item {
  margin: 5px;
}

@supports (gap: 10px) {
  .container {
    gap: 10px;
    margin: 0;
  }
  .item {
    margin: 0;
  }
}

方案二:PostCSS 自动前缀

npm install postcss-flex-gap-polyfill --save-dev

配置 postcss.config.js:

module.exports = {
  plugins: [
    require('postcss-flex-gap-polyfill')
  ]
}

方案三:智能间距系统

@mixin flex-gap($gap) {
  @supports (gap: $gap) {
    gap: $gap;
  }

  &:not(:has(> :first-child)) {
    margin: 0;
  }

  > * {
    margin: $gap / 2;
    
    @supports (gap: $gap) {
      margin: 0;
    }
  }
}

.container {
  @include flex-gap(16px);
}

2.3 兼容性方案对比

方案 优点 缺点
Margin 负值 兼容性好 计算复杂,难以嵌套
特性查询 代码清晰 需要维护两套样式
PostCSS 插件 自动处理 增加构建复杂度
CSS Grid 回退 现代浏览器性能优化 IE 完全不支持 Grid

2.4 实战建议

  1. 移动端优先:iOS 14.5+ 已支持 flex-gap,可适当放宽兼容要求
  2. 服务端渲染检测:配合 modernizr 输出不同 HTML 结构
  3. 设计系统整合:将间距方案抽象为 CSS 变量
:root {
  --gutter: 16px;
}

.container {
  gap: var(--gutter);
  margin: calc(-1 * var(--gutter));
}

三、综合解决方案示例

3.1 完美响应式布局

<div class="card-list">
  <div class="card">
    <img src="thumbnail.jpg" alt="">
    <h3>Card Title</h3>
    <p>Description text...</p>
  </div>
  <!-- 重复多个 card -->
</div>

<style>
.card-list {
  --gap: 24px;
  
  display: flex;
  flex-wrap: wrap;
  margin: calc(-1 * var(--gap)) 0 0 calc(-1 * var(--gap));
}

.card {
  flex: 1 0 300px;
  margin: var(--gap) 0 0 var(--gap);
  
  /* 内容保护 */
  min-width: 280px;
  max-width: 400px;
}

@supports (gap: var(--gap)) {
  .card-list {
    gap: var(--gap);
    margin: 0;
  }
  .card {
    margin: 0;
  }
}
</style>

3.2 性能优化技巧

  1. will-change 加速:对频繁变化的容器使用 will-change: transform
  2. 避免嵌套 gap:多层嵌套布局使用 padding 替代
  3. CSS 自定义属性:统一管理间距系统
  4. 逻辑属性:使用 margin-inline-start 等属性支持 RTL 语言

四、调试工具推荐

  1. 浏览器开发者工具

    • Chrome 的 Flexbox 调试面板
    • Firefox 的 Flexbox Inspector
  2. 在线检测

    • Can I Use(caniuse.com)
    • Autoprefixer 在线演示
  3. 可视化工具

五、未来展望

随着 CSS 规范的演进,新的布局方式正在出现:

  1. 容器查询:更精细的响应式控制
  2. subgrid:复杂嵌套布局的终极方案
  3. CSS 嵌套:提升样式可维护性
  4. Viewport 单位改进:更好的移动端适配

总结

通过本文的深度解析,我们掌握了:

  • flex-shrink 导致内容压缩的底层机制与 3 种解决方案
  • gap 属性的 4 种兼容性处理方案及优劣对比
  • 响应式布局中的 5 个实用技巧
  • 现代 CSS 布局的最佳实践路线

建议在实际项目中:

  1. 建立完善的样式校验机制
  2. 使用现代构建工具链(如 Vite + PostCSS)
  3. 制定团队级的 CSS 编写规范
  4. 定期进行跨浏览器测试

随着浏览器生态的不断发展,Flexbox 与 Grid 的配合使用将成为主流布局方案。理解这些核心问题的解决思路,将帮助开发者更好地应对未来新的 CSS 特性挑战。

一、【UnoCss / unibest】样式篇

作者 邵洛
2025年4月18日 11:16

1、废话介绍

UnoCSS 是由 Windi CSS 的一名团队成员发起的,从我们在 Windi CSS 中所做的工作中汲取了很多灵感。虽然 Windi CSS 不再被积极维护(截至 2023 年 3 月),但你可以将 UnoCSS 视为 Windi CSS 的 "精神继承者"。 UnoCSS 继承了 Windi CSS 的按需特性,属性化模式快捷方式变体组编译模式 等等。最重要的是,UnoCSS 是从头开始构建的,考虑到了最大的可扩展性和性能,使我们能够引入 纯 CSS 图标无值的属性化标签化网络字体 等新功能。


unibest 是最好的 uniapp 开发框架,由 uniapp + Vue3 + Ts + Vite5 + UnoCss + VSCode(可选 webstorm) + uni插件wot-ui(可选其他 UI 库)构建,集成了多种工具和技术,使用了最新的前端技术栈,无需依靠 HBuilderX,通过命令行方式即可运行 web小程序 和 App。(注:App 还是需要 HBuilderX

unibest 内置了 约定式路由layout布局请求封装请求拦截登录拦截UnoCSSi18n多语言 等基础功能,提供了 代码提示自动格式化统一配置代码片段 等辅助功能,让你编写 uniapp 拥有 best 体验 ( unibest 的由来)。

unibest 目前支持 H5小程序 和 App

2、UnoCSS / unibest

本篇主要介绍 UnoCSS 的使用,以及如何与 设计稿尺寸 对应。 UnoCSS 是按需使用的原子 CSS 引擎,提供了良好的样式支持。

3、安装插件

请安装 VSCode 插件 antfu.unocss

image.png

安装完成后在 VSCode 中还可以预览,

image.png

image.png

如果不记得原子类,可以查 UnoCSS 的原子类UnoCSS Interactive

也可以查看 tailwindcss 的原子类,更加清晰明了,链接 - tailwindcss

4、设计稿尺寸

不同的编写方式,需要设置不同的设计稿尺寸,请看下文:

4.1、传统编写方式

如果有设计稿,通常使用传统的编写 CSS 的方式,里面的对应尺寸规律如下。以蓝湖为例,假如设计稿宽度为 750px,则直接复制样式代码到 css 代码,同时把 px 批量替换为 rpx 即可。

如果设计稿不是 750px 可以调整蓝湖的设置,让设计稿宽度为 750px

下面为一段辅助说明文案,从 uniapp 官网搬运而来。

rpx 是相对于基准宽度的单位,可以根据屏幕宽度进行自适应。uni-app 规定屏幕基准宽度 750rpx

开发者可以通过设计稿基准宽度计算页面元素 rpx 值,设计稿 1px 与框架样式 1rpx 转换公式如下:

设计稿 1px / 设计稿基准宽度 = 框架样式 1rpx / 750rpx

换言之,页面元素宽度在 uni-app 中的宽度计算公式:

750 * 元素在设计稿中的宽度 / 设计稿基准宽度

举例说明:

若设计稿宽度为 750px,元素 A 在设计稿上的宽度为 100px,那么元素 Auni-app 里面的宽度应该设为:750 * 100 / 750,结果为:100rpx

若设计稿宽度为 640px,元素 A 在设计稿上的宽度为 100px,那么元素 Auni-app 里面的宽度应该设为:750 * 100 / 640,结果为:117rpx

若设计稿宽度为 375px,元素 B 在设计稿上的宽度为 200px,那么元素 Buni-app 里面的宽度应该设为:750 * 200 / 375,结果为:400rpx

4.2、UnoCSS 编写方式

经过上一节的 unocss.config.ts 配置,可以得到下面的组合:

mp 里面:mt-4 => margin-top: 32rpx == 16px

h5 里面:mt-4 => margin-top: 1rem == 16px

我们还是把设计稿设置为 750,设计稿上多少 px 的元素,写成多少 rpx 即可。

元素 A 在设计稿上的宽度为 100px,则写 w-100rpx 即可。

就是把 传统编写方式 中写在 css 中的样式搬到了 UnoCSS 中。

如果要想用 w-100 这种方式,需要做额外的处理():

5、使用指南

写法可以参考 tailwindcss

太忙了,有空再写吧。

总结

本文主要介绍了 UnoCSS 的使用,以及 unocss.config.ts 中的一些配置项。

同时说明了设计稿在两种编写方式下的宽度的设置,分别为 7501500.

最后说明一下,原子化CSS传统方式 两者不是互斥的,他们是互补的,合适的地方使用合适的方式。

前端微应用-乾坤(qiankun)原理分析-import-html-entry

作者 三原
2025年4月18日 11:16

import-html-entry做为解析不同乾坤(qiankun)入口文件的依赖,主要功能是解析html文件,并返回一个promise对象,返回的promise对象中包含html文件解析后的内容,以及html文件解析后的script标签。

也是因为有这个库在,你才能像iframe样使用qiankun, 子应用预加载跟缓存核心也很该插件挂钩。

本篇主要分析一下import-html-entry,以及qiankun应用基础使用。

乾坤的基本使用

乾坤的概念是不依赖任何框架,基座可以是任意框架或者HTML,子应用也是同样。

  1. 基座应用
import Vue from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import { registerMicroApps, start, loadMicroApp } from 'qiankun';

Vue.config.productionTip = false

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");

const commonComponents = {};
registerMicroApps([
  { 
    name: 'app-vue-hash', 
    entry: 'http://localhost:1111', 
    container: '#appContainer',  // 挂载在那个容器下
    activeRule: '/app-vue-hash', // 不过是key名字变了
    props: { data : { store, router, loadMicroApp, commonComponents } }
  },
  { 
    name: 'app-vue-history',
    entry: 'http://localhost:2222', 
    container: '#appContainer', 
    activeRule: '/app-vue-history',
    props: { data : store }
  },
]);

// 共享组件必须开启多实例
start({ singular: false });
  1. 子应用的应用

第一个vue应用指定端口1111,第二个应用指定端口2222,这只是在本地调试,如果部署线上环境,需要修改entry地址。

子应用暴漏mount、unmount、bootstrap、update相关API,如下:


import './public-path';
import Vue from 'vue';
import VueRouter from 'vue-router';
import App from './App.vue';
import routes from './router';
import store from './store';
import HelloWorld from '@/components/HelloWorld.vue'

Vue.config.productionTip = false;

let router = null;
let instance = null;

function render() {
  router = new VueRouter({
    base: window.__POWERED_BY_QIANKUN__ ? '/app-vue-history' : '/',
    mode: 'history',
    routes,
  });

  instance = new Vue({
    router,
    store,
    render: h => h(App),
  }).$mount('#appVueHistory');
}

if (!window.__POWERED_BY_QIANKUN__) {
  render();
}
//测试全局变量污染
window.a = 1;
export async function bootstrap() {
  console.log('vue app bootstraped');
}

export async function mount(props) {
  console.log('props from main framework', props);
  if(props.data.commonComponents){
    props.data.commonComponents.HelloWorld = HelloWorld
  }else{
    render();
  }
  // 测试一下 body 的事件,不会被沙箱移除
  // document.body.addEventListener('click', e => console.log('document.body.addEventListener'))
  // document.body.onclick = e => console.log('document.body.addEventListener')
}

export async function unmount() {
  if(instance){
    instance.$destroy();
    instance.$el.innerHTML = "";
    instance = null;
    router = null;
  }
}

这里不讲怎么使用qiankun,只是为了import-html-entryentry引入下基本用法,如果想获取demo,可以通过龚顺大佬的git获取,里面有各种场景的demo,可以按需了解,对初步接触者非常友好。

import-html-entry插件

这个插件干了啥呢?主要是用来解析HTML文件,咱们先看一个简单的react打包后的HTML资源:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <script type="module" crossorigin src="/assets/index-CY8RT7Xj.js"></script>
    <link rel="stylesheet" crossorigin href="/assets/index-Cd5-0EfR.css">
  </head>
  <body>
    <div id="root"></div>
  </body>
</html>

经过import-html-entry后会被解析成这个样子:

5.png

import { importEntry } from 'import-html-entry';

const { template, execScripts, assetPublicPath, getExternalScripts,getExternalStyleSheets } = await importEntry('http://localhost:1111');

/**
 * assetPublicPath: 资源路径
 * template: HTML字符串 不包含script标签 link标签会被转成style标签
 * execScripts: 运行存放的script标签并且获取到导出的生命周期函数
 * getExternalScripts: 存放HTML中的js
 * getExternalStyleSheets 存放HTML中的css
 */
`
"<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite + React</title>
    <!--   script http://127.0.0.1:5500/assets/index-CY8RT7Xj.js replaced by import-html-entry -->
    <style>/* http://127.0.0.1:5500/assets/index-Cd5-0EfR.css */:root{font-family:system-ui,Avenir,Helvetica,Arial,sans-serif;line-height:1.5;font-weight:400;color-scheme:light dark;color:#ffffffde;background-color:#242424;font-synthesis:none;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}a{font-weight:500;color:#646cff;text-decoration:inherit}a:hover{color:#535bf2}body{margin:0;min-width:320px;min-height:100vh}h1{font-size:3.2em;line-height:1.1}button{border-radius:8px;border:1px solid transparent;padding:.6em 1.2em;font-size:1em;font-weight:500;font-family:inherit;background-color:#1a1a1a;cursor:pointer;transition:border-color .25s}button:hover{border-color:#646cff}button:focus,button:focus-visible{outline:4px auto -webkit-focus-ring-color}@media (prefers-color-scheme: light){:root{color:#213547;background-color:#fff}a:hover{color:#747bff}button{background-color:#f9f9f9}}#root{height:100%}.logo{height:6em;padding:1.5em;will-change:filter;transition:filter .3s}.logo:hover{filter:drop-shadow(0 0 2em #646cffaa)}.logo.react:hover{filter:drop-shadow(0 0 2em #61dafbaa)}@keyframes logo-spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}@media (prefers-reduced-motion: no-preference){a:nth-of-type(2) .logo{animation:logo-spin infinite 20s linear}}.card{padding:2em}.read-the-docs{color:#888}
</style>
  </head>
  <body>
    <div id="root"></div>
  
<!-- inline scripts replaced by import-html-entry -->
</body>
</html>
"
`

他是怎么做到获取对应的HTML呢(entry: 'http://localhost:1111', )?

默认是通过fetch,当然你这个可以自定义如下:

fetch('http://localhost:1111').then((res) => console.log(res.text()))

拿到文本后利用正则做文本替换抽离:(取style、以及script)

import { getInlineCode, isModuleScriptSupported } from './utils';

const ALL_SCRIPT_REGEX = /(<script[\s\S]*?>)[\s\S]*?<\/script>/gi;
const SCRIPT_TAG_REGEX = /<(script)\s+((?!type=('|')text\/ng-template\3).)*?>.*?<\/\1>/is;
const SCRIPT_SRC_REGEX = /.*\ssrc=('|")?([^>'"\s]+)/;
const SCRIPT_TYPE_REGEX = /.*\stype=('|")?([^>'"\s]+)/;
const SCRIPT_ENTRY_REGEX = /.*\sentry\s*.*/;
const SCRIPT_ASYNC_REGEX = /.*\sasync\s*.*/;
const SCRIPT_NO_MODULE_REGEX = /.*\snomodule\s*.*/;
const SCRIPT_MODULE_REGEX = /.*\stype=('|")?module('|")?\s*.*/;
const LINK_TAG_REGEX = /<(link)\s+.*?>/isg;
const LINK_PRELOAD_OR_PREFETCH_REGEX = /\srel=('|")?(preload|prefetch)\1/;
const LINK_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const LINK_AS_FONT = /.*\sas=('|")?font\1.*/;
const STYLE_TAG_REGEX = /<style[^>]*>[\s\S]*?<\/style>/gi;
const STYLE_TYPE_REGEX = /\s+rel=('|")?stylesheet\1.*/;
const STYLE_HREF_REGEX = /.*\shref=('|")?([^>'"\s]+)/;
const HTML_COMMENT_REGEX = /<!--([\s\S]*?)-->/g;
const LINK_IGNORE_REGEX = /<link(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const STYLE_IGNORE_REGEX = /<style(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;
const SCRIPT_IGNORE_REGEX = /<script(\s+|\s+.+\s+)ignore(\s*|\s+.*|=.*)>/is;

export default function processTpl(tpl, baseURI) {

let scripts = [];
const styles = [];
let entry = null;
const moduleSupport = isModuleScriptSupported();

const template = tpl

/*
remove html comment first
*/
.replace(HTML_COMMENT_REGEX, '') // remove html comment

.replace(LINK_TAG_REGEX, match => { // 替换link标签 并且styles。push(URL)
})
.replace(STYLE_TAG_REGEX, match => { // style 这里主要是做忽略用的标签
if (STYLE_IGNORE_REGEX.test(match)) {
return genIgnoreAssetReplaceSymbol('style file');
}
return match;
})
.replace(ALL_SCRIPT_REGEX, (match, scriptTag) => { // script 标签处理
const scriptIgnore = scriptTag.match(SCRIPT_IGNORE_REGEX);
});

scripts = scripts.filter(function (script) {
// filter empty script
return !!script;
});

return {
template,
scripts,
styles,
// set the last script as entry if have not set
entry: entry || scripts[scripts.length - 1],
};
}

execScripts执行对应的scripts

通过存储的scripts可以获取到对应的js code,然后使用eval执行这些js code;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);)用with包裹了一层

getExternalScripts,getExternalStyleSheets两个方法主要是给qiankun做应用预加载用的。

咱们从上面的运行可以看到getExternalScripts,getExternalStyleSheets可以拿到对应的代码。然后等切换到该子应用后直接执行该代码。

总结

关于为什么要用import-html-entry解析html这么做呢?

  • 第一点是为了获取子应用导出的生命周期(mount、bootstrap、unmount、update),如果不这么处理大家可以想想,让它自己通过script执行后咱们要怎么才能获取到呢? 放windows上也算是种方案。还有别的吗?

  • 第二点是为了能实现预加载,提前获取到别的子应用的一些资源,等切换到该子应用的话就能直接载对应的文件就行。

Q: 如果qiankun没有配置预加载(prefetchApps)跟iframe不就一样了?资源还都是要载的不过是换个形式?那为什么说性能有提升呢?

A: 确实是这个样,qiankun通过fetch的形式也是把对应的资源加载了。但是qiankun缓存了加载过的子应用信息,可以避免重复加载。这点比iframe要优。

Q:解析HTML是编译时还是运行时

A: 运行时,不管是不是预加载,都需要在运行时解析HTML。预加载不过是趁着浏览器空隙时执行。

Node.js 学习第一天:入门指南

作者 Mintopia
2025年4月18日 11:14

引言

Node.js 是一个基于 Chrome V8 引擎的 JavaScript 运行环境,让 JavaScript 可以在服务器端运行。它使得开发者可以使用 JavaScript 构建高性能、可扩展的网络应用程序。在第一天的学习中,我们将完成环境搭建,并了解 Node.js 的基本概念和简单应用。

一、环境搭建

1. 下载与安装

Node.js 的官方网站是nodejs.org/ ,在这里你可以找到适合你操作系统的安装包。对于大多数开发者来说,建议下载长期支持(LTS)版本,因为它更加稳定。

安装过程非常简单,按照安装向导的提示一步一步进行即可。安装完成后,你可以通过以下命令来验证是否安装成功:

node -v

如果安装成功,该命令会输出 Node.js 的版本号。同时,Node.js 自带了包管理工具 npm(Node Package Manager),你可以使用以下命令验证 npm 的安装:

npm -v

2. 集成开发环境(IDE)选择

有许多 IDE 可以用于 Node.js 开发,以下是一些常见的选择:

  • Visual Studio Code:这是一个免费且功能强大的开源 IDE,拥有丰富的插件生态系统,对 Node.js 开发提供了很好的支持。
  • WebStorm:由 JetBrains 开发的专业 JavaScript IDE,具备智能代码提示、调试工具等高级功能,但它是付费软件。

二、Node.js 基本概念

1. 单线程与事件驱动

Node.js 采用单线程、事件驱动的架构。单线程意味着在同一时间只能执行一个任务,但通过事件驱动和非阻塞 I/O 操作,Node.js 可以高效地处理大量并发请求。

例如,当一个 Node.js 程序发起一个文件读取请求时,它不会等待文件读取完成,而是继续执行后续的代码。当文件读取完成后,会触发一个事件,Node.js 会处理这个事件。

2. 模块系统

Node.js 使用模块系统来组织代码。一个模块就是一个独立的 JavaScript 文件,它可以包含变量、函数和类等。Node.js 内置了许多核心模块,如fs(文件系统模块)、http(HTTP 服务器模块)等。

以下是一个简单的模块示例:

// math.js
function add(a, b) {
    return a + b;
}
function subtract(a, b) {
    return a - b;
}
module.exports = {
    add: add,
    subtract: subtract
};

在另一个文件中,我们可以引入并使用这个模块:

// main.js
const math = require('./math');
const result1 = math.add(5, 3);
const result2 = math.subtract(5, 3);
console.log('加法结果:', result1);
console.log('减法结果:', result2);

3. 全局对象

在 Node.js 中,有一些全局对象可以在任何模块中使用。其中最常用的是global对象,它类似于浏览器中的window对象。

以下是一些常见的全局对象和方法:

  • console:用于在控制台输出信息,如console.log()、console.error()等。
  • __dirname:表示当前模块所在的目录路径。
  • __filename:表示当前模块的文件路径。
console.log('当前目录:', __dirname);
console.log('当前文件:', __filename);

三、第一个 Node.js 程序

1. 创建一个简单的 HTTP 服务器

使用 Node.js 的http模块,我们可以轻松创建一个简单的 HTTP 服务器。以下是一个示例代码:

const http = require('http');
const server = http.createServer((req, res) => {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/plain');
    res.end('Hello, World!\n');
});
const port = 3000;
server.listen(port, () => {
    console.log(`服务器正在监听端口 ${port}`);
});

将上述代码保存为server.js,然后在终端中运行以下命令:

node server.js

打开浏览器,访问http://localhost:3000,你将看到页面上显示Hello, World!。

2. 代码解释

  • http.createServer():创建一个 HTTP 服务器实例,它接受一个回调函数作为参数,该回调函数在每次收到 HTTP 请求时都会被调用。
  • res.statusCode:设置 HTTP 响应的状态码,这里设置为 200 表示请求成功。
  • res.setHeader():设置 HTTP 响应的头部信息,这里设置响应内容的类型为纯文本。
  • res.end():结束响应并发送响应内容。
  • server.listen():让服务器开始监听指定的端口。

四、总结

在第一天的学习中,我们完成了 Node.js 的环境搭建,了解了 Node.js 的基本概念,包括单线程与事件驱动、模块系统和全局对象。同时,我们还创建了一个简单的 HTTP 服务器。这些知识是学习 Node.js 的基础,在后续的学习中,我们将深入探讨 Node.js 的更多功能和应用。

希望你通过今天的学习,对 Node.js 有了一个初步的认识,并能够顺利运行第一个 Node.js 程序。在接下来的学习中,你可以尝试修改代码,添加更多的功能,进一步熟悉 Node.js 的开发。

Three.js 学习第一天:基础搭建与核心概念

作者 Mintopia
2025年4月18日 11:09

在当今的 Web 开发领域,为用户呈现丰富、沉浸式的体验至关重要。Three.js 作为一个强大的 JavaScript 库,能够助力开发者在网页中轻松创建出令人惊叹的 3D 场景。接下来,让我们开启为期 100 天的 Three.js 学习之旅,今天是第一天,我们将聚焦于环境搭建以及几个核心概念。

一、开发环境搭建

在开始编写 Three.js 代码之前,需要准备好相应的开发环境。

(一)文本编辑器

推荐使用 Visual Studio Code(简称 VS Code),它功能强大,拥有丰富的插件生态系统,能极大提升开发效率。你可以从官网下载并安装。

(二)本地服务器

由于浏览器的安全策略,直接打开本地的 HTML 文件可能无法正常加载 Three.js 资源。因此,需要搭建一个本地服务器。在 VS Code 中,安装 “Live Server” 插件,安装完成后,在 HTML 文件中右键选择 “Open with Live Server”,即可启动本地服务器。

(三)引入 Three.js 库

有两种常见方式引入 Three.js 库:

  1. CDN 链接:在 HTML 文件的标签内添加如下代码:
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r134/three.min.js"></script>

这会从 CDN(内容分发网络)加载 Three.js 的压缩版本,方便快捷。

  1. 下载库文件:从Three.js 官下载库文件,解压后将three.min.js文件放在项目目录中,然后在 HTML 文件中通过相对路径引入:
<script src="path/to/three.min.js"></script>

这里的path/to需要替换为实际存放three.min.js文件的路径。

二、Three.js 核心概念

(一)场景(Scene)

场景是 Three.js 中所有 3D 对象的容器,它就像是一个舞台,所有的 “表演”(物体、灯光等)都在这里进行。通过以下代码创建一个场景:

const scene = new THREE.Scene();

场景对象scene创建完成后,后续创建的所有物体、灯光等都要添加到这个场景中,才能最终被渲染出来。

(二)相机(Camera)

相机决定了从哪个角度观察场景,它类似于我们现实生活中的眼睛。Three.js 提供了多种相机类型,最常用的是透视相机(PerspectiveCamera),它模拟人眼的视觉效果,符合近大远小的透视规律。创建一个透视相机的代码如下:

const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

这里的参数解释如下:

  • 75:相机的视野角度(FOV),即垂直方向上能看到的角度范围,值越大,看到的场景范围越广。
  • window.innerWidth / window.innerHeight:相机的宽高比,通常设置为浏览器窗口的宽高比,以保证场景在不同尺寸屏幕上正确显示。
  • 0.1:相机的近裁剪平面,距离相机小于这个值的物体将不会被渲染。
  • 1000:相机的远裁剪平面,距离相机大于这个值的物体将不会被渲染。

创建相机后,还需要设置其位置,使其能看到场景中的物体,例如:

camera.position.set(0, 0, 5);

上述代码将相机设置在(0, 0, 5)的位置,其中三个值分别对应三维坐标系中的x、y、z轴坐标。最后,要将相机添加到场景中:

scene.add(camera);

(三)几何体(Geometry)

几何体定义了 3D 物体的形状。Three.js 内置了许多常用的几何体,如立方体(BoxGeometry)、球体(SphereGeometry)、圆柱体(CylinderGeometry)等。以创建一个立方体几何体为例:

const geometry = new THREE.BoxGeometry(1, 1, 1);

这里的三个参数分别表示立方体的宽度、高度和深度,单位是任意的,这里设置为 1。

(四)材质(Material)

材质决定了物体的外观,比如颜色、纹理、透明度等。不同的材质类型会有不同的表现效果。例如,使用基础材质(MeshBasicMaterial)创建一个红色的材质:

const material = new THREE.MeshBasicMaterial({ color: 0xff0000 });

0xff0000是十六进制的颜色值,表示红色。

(五)网格(Mesh)

网格是由几何体和材质组合而成的 3D 物体,它将几何体的形状和材质的外观结合起来,最终呈现在场景中。创建一个网格对象的代码如下:

const mesh = new THREE.Mesh(geometry, material);

这里使用之前创建的geometry和material来创建网格。创建完成后,将网格添加到场景中:

scene.add(mesh);

(六)渲染器(Renderer)

渲染器负责将场景和相机的信息渲染到屏幕上。最常用的是 WebGL 渲染器(WebGLRenderer),它利用浏览器的 GPU 加速来高效渲染 3D 场景。创建一个 WebGL 渲染器的代码如下:

const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

renderer.setSize(window.innerWidth, window.innerHeight)设置渲染器的大小为浏览器窗口的大小,这样渲染出来的场景能铺满整个窗口。document.body.appendChild(renderer.domElement)将渲染器生成的 DOM 元素添加到 HTML 页面的标签内,从而在页面中显示出渲染结果。最后,通过以下代码执行渲染操作:

renderer.render(scene, camera);

这行代码告诉渲染器使用指定的相机来渲染场景。

通过今天的学习,我们完成了 Three.js 开发环境的搭建,并了解了场景、相机、几何体、材质、网格和渲染器这些核心概念,还通过代码创建了一个简单的 3D 场景。在接下来的学习中,我们将逐步深入,探索 Three.js 更多强大的功能。

第十篇:【React SSR 与 SSG】服务端渲染与静态生成实战指南

2025年4月18日 11:00

告别白屏加载!React 应用性能与 SEO 的终极提升方案

各位 React 开发者,你是否曾经面临过这些困扰:

  • 首屏加载时间过长,用户体验差?
  • 搜索引擎无法有效索引你的应用内容?
  • 大型应用的性能与可扩展性问题?
  • 复杂应用架构的选型困惑?

在过去的九篇文章中,我们详细探讨了 React 客户端渲染(CSR)的各种优化方法。今天,我们将揭开全新的篇章,探索服务端渲染(SSR)与静态站点生成(SSG)这两种强大的渲染策略,帮助你的 React 应用更快、更强、对 SEO 更友好!

1. 为什么需要 SSR 与 SSG?

在深入技术细节前,让我们先理解这些渲染策略解决的核心问题:

┌────────────────────────────────────────────────────┐
│ 渲染策略比较                                        │
├────────────┬───────────────┬───────────┬───────────┤
│            │ CSR(客户端渲染)│SSR(服务端渲染)│SSG(静态生成)│
├────────────┼───────────────┼───────────┼───────────┤
│ 首屏加载    │    慢    │    快    │   最快   │
│ SEO支持     │    差    │    好    │    好    │
│ 服务器负载  │    低    │    高    │    低    │
│ 内容更新    │  实时更新  │  每次请求  │ 构建时生成 │
│ 交互性      │    高    │    中    │    中    │
└────────────┴───────────────┴───────────┴───────────┘

Next.js:React SSR/SSG 的首选框架

Next.js 是 React SSR/SSG 应用开发的事实标准,它提供了:

  • 零配置的服务端渲染
  • 自动代码分割
  • 基于文件系统的路由
  • API 路由内置支持
  • 多种渲染模式灵活切换
  • 增量静态再生(ISR)

让我们从头开始构建一个 Next.js 应用,体验这一切的美妙:

# 创建新的Next.js应用
npx create-next-app@latest my-nextjs-app
cd my-nextjs-app
npm run dev

2. Next.js 应用核心结构与配置

理解 Next.js 的项目结构是掌握它的第一步:

my-nextjs-app/
├── app/                    # App Router (Next.js 13+)
│   ├── layout.tsx          # 根布局组件
│   ├── page.tsx            # 首页
│   ├── about/              # 关于页面路由
│   │   └── page.tsx        # 关于页面
│   ├── blog/               # 博客路由
│   │   ├── [slug]/         # 动态路由
│   │   │   └── page.tsx    # 博客文章页面
│   │   └── page.tsx        # 博客列表页面
│   └── api/                # API路由
│       └── hello/
│           └── route.ts    # API端点
├── components/             # 共享组件
├── public/                 # 静态资源
├── styles/                 # 样式文件
├── lib/                    # 工具库
├── next.config.js          # Next.js配置
├── package.json            # 项目依赖
└── tsconfig.json           # TypeScript配置
// app/layout.tsx - 根布局组件
import { Inter } from "next/font/google";
import { Metadata } from "next";
import "./globals.css";

// 使用Google字体
const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "我的Next.js应用",
  description: "使用Next.js构建的现代React应用",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode,
}) {
  return (
    <html lang="zh-CN">
      <body className={inter.className}>
        <header className="bg-blue-600 text-white p-4">
          <div className="container mx-auto flex justify-between items-center">
            <h1 className="text-2xl font-bold">我的Next.js应用</h1>
            <nav>
              <ul className="flex space-x-4">
                <li>
                  <a href="/" className="hover:underline">
                    首页
                  </a>
                </li>
                <li>
                  <a href="/about" className="hover:underline">
                    关于
                  </a>
                </li>
                <li>
                  <a href="/blog" className="hover:underline">
                    博客
                  </a>
                </li>
              </ul>
            </nav>
          </div>
        </header>
        <main className="container mx-auto py-8 px-4">{children}</main>
        <footer className="bg-gray-100 p-4 mt-8">
          <div className="container mx-auto text-center text-gray-600">
            © {new Date().getFullYear()} 我的Next.js应用 | 使用Next.js构建
          </div>
        </footer>
      </body>
    </html>
  );
}
// app/page.tsx - 首页(使用服务端组件)
export default function Home() {
  return (
    <div className="space-y-8">
      <section className="text-center py-12">
        <h1 className="text-4xl font-bold mb-4">欢迎来到Next.js世界</h1>
        <p className="text-xl text-gray-600 max-w-2xl mx-auto">
          探索服务端渲染、静态站点生成和React应用开发的全新可能性
        </p>
      </section>

      <section className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">服务端渲染</h2>
          <p className="text-gray-600">
            每次请求时在服务器渲染页面,适合动态内容
          </p>
        </div>
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">静态站点生成</h2>
          <p className="text-gray-600">
            在构建时预渲染页面,提供极快的加载速度
          </p>
        </div>
        <div className="border rounded-lg p-6 shadow-sm">
          <h2 className="text-2xl font-bold mb-3">增量静态再生</h2>
          <p className="text-gray-600">静态生成与按需更新的完美结合</p>
        </div>
      </section>

      <section className="text-center">
        <a
          href="/blog"
          className="inline-block bg-blue-600 text-white px-6 py-3 rounded-md hover:bg-blue-700 transition"
        >
          浏览博客文章
        </a>
      </section>
    </div>
  );
}

3. 不同渲染模式的实现方式

Next.js 13 的 App Router 提供了强大的服务端组件(RSC),但也支持多种渲染模式:

静态站点生成(SSG)

// app/blog/page.tsx - 静态生成的博客列表页
import Link from "next/link";
import { getBlogPosts } from "@/lib/blog";

// 这个函数在构建时执行,用于静态生成页面
export async function generateStaticParams() {
  return [{}]; // 静态生成博客列表页
}

// 默认情况下,App Router中的页面组件是服务端组件
// 当使用generateStaticParams时,它们会在构建时静态生成
export default async function BlogListPage() {
  // 在构建时获取博客文章
  const posts = await getBlogPosts();

  return (
    <div>
      <h1 className="text-3xl font-bold mb-6">博客文章</h1>

      <div className="grid gap-6">
        {posts.map((post) => (
          <article key={post.slug} className="border rounded-lg p-6 shadow-sm">
            <h2 className="text-2xl font-bold mb-2">
              <Link
                href={`/blog/${post.slug}`}
                className="hover:text-blue-600 transition"
              >
                {post.title}
              </Link>
            </h2>
            <p className="text-gray-600 mb-4">{post.excerpt}</p>
            <div className="text-sm text-gray-500">
              发布于 {new Date(post.date).toLocaleDateString("zh-CN")}
            </div>
          </article>
        ))}
      </div>
    </div>
  );
}
// app/blog/[slug]/page.tsx - 动态博客文章页面
import { getBlogPosts, getBlogPostBySlug } from "@/lib/blog";
import { notFound } from "next/navigation";
import { Metadata } from "next";

// 为每篇文章生成静态页面
export async function generateStaticParams() {
  const posts = await getBlogPosts();

  return posts.map((post) => ({
    slug: post.slug,
  }));
}

// 动态生成元数据
export async function generateMetadata({
  params,
}: {
  params: { slug: string },
}): Promise<Metadata> {
  const post = await getBlogPostBySlug(params.slug);

  if (!post) {
    return {
      title: "文章未找到",
    };
  }

  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      type: "article",
      publishedTime: post.date,
      authors: [post.author.name],
    },
  };
}

export default async function BlogPostPage({
  params,
}: {
  params: { slug: string },
}) {
  const post = await getBlogPostBySlug(params.slug);

  if (!post) {
    notFound();
  }

  return (
    <article className="max-w-3xl mx-auto">
      <h1 className="text-4xl font-bold mb-4">{post.title}</h1>

      <div className="flex items-center space-x-4 mb-8 text-gray-600">
        <div className="flex items-center">
          <img
            src={post.author.avatar}
            alt={post.author.name}
            className="w-10 h-10 rounded-full mr-3"
          />
          <span>{post.author.name}</span>
        </div>
        <span></span>
        <time dateTime={post.date}>
          {new Date(post.date).toLocaleDateString("zh-CN", {
            year: "numeric",
            month: "long",
            day: "numeric",
          })}
        </time>
      </div>

      <div
        className="prose prose-lg max-w-none"
        dangerouslySetInnerHTML={{ __html: post.content }}
      />
    </article>
  );
}

增量静态再生(ISR)

// lib/blog.ts - 博客数据获取逻辑
import fs from 'fs/promises';
import path from 'path';
import matter from 'gray-matter';
import { remark } from 'remark';
import html from 'remark-html';
import { cache } from 'react';

export interface BlogPost {
  slug: string;
  title: string;
  date: string;
  excerpt: string;
  content: string;
  author: {
    name: string;
    avatar: string;
  };
}

// 使用React的cache包装函数避免重复获取
export const getBlogPosts = cache(async (): Promise<BlogPost[]> => {
  const postsDirectory = path.join(process.cwd(), 'content/blog');
  const filenames = await fs.readdir(postsDirectory);

  const posts = await Promise.all(
    filenames.map(async (filename) => {
      const filePath = path.join(postsDirectory, filename);
      const fileContent = await fs.readFile(filePath, 'utf8');

      const { data, content } = matter(fileContent);
      const slug = filename.replace(/\.md$/, '');

      // 处理Markdown转HTML
      const processedContent = await remark()
        .use(html)
        .process(content);
      const contentHtml = processedContent.toString();

      return {
        slug,
        title: data.title,
        date: data.date,
        excerpt: data.excerpt || '',
        content: contentHtml,
        author: data.author || {
          name: '博客作者',
          avatar: '/images/default-avatar.png',
        },
      } as BlogPost;
    })
  );

  // 按日期排序
  return posts.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
});

export async function getBlogPostBySlug(slug: string): Promise<BlogPost | null> {
  const posts = await getBlogPosts();
  return posts.find((post) => post.slug === slug) || null;
}
// next.config.js - 配置ISR
/** @type {import('next').NextConfig} */
const nextConfig = {
  // App Router模式下,可以通过页面导出的revalidate属性配置ISR
  // 或者通过fetch的next.revalidate选项
  experimental: {
    // 启用实验性功能(根据Next.js版本可能不需要)
    serverActions: true,
  },
};

module.exports = nextConfig;
// app/products/[id]/page.tsx - 使用ISR的产品页面
import { getProduct, getProductIds } from "@/lib/products";
import { notFound } from "next/navigation";
import Image from "next/image";
import AddToCartButton from "@/components/AddToCartButton";

// 在构建时静态生成一部分产品页面
export async function generateStaticParams() {
  // 获取热门产品ID,只预渲染这些页面
  const popularProductIds = await getPopularProductIds();

  return popularProductIds.map((id) => ({
    id: id.toString(),
  }));
}

// 设置该页面每60秒可以重新验证(ISR)
export const revalidate = 60;

export default async function ProductPage({
  params,
}: {
  params: { id: string },
}) {
  // 使用fetch API时也可以设置revalidate
  const product = await getProduct(params.id);

  if (!product) {
    notFound();
  }

  return (
    <div className="grid grid-cols-1 md:grid-cols-2 gap-8">
      <div>
        <Image
          src={product.imageUrl}
          alt={product.name}
          width={600}
          height={600}
          className="rounded-lg"
          priority
        />
      </div>

      <div>
        <h1 className="text-3xl font-bold mb-4">{product.name}</h1>
        <div className="text-2xl font-semibold text-blue-600 mb-4">
          ¥{product.price.toFixed(2)}
        </div>

        <div className="mb-6">
          <div className="bg-green-50 text-green-700 px-4 py-2 rounded-md inline-block">
            有货 - 预计3-5天送达
          </div>
        </div>

        <p className="text-gray-700 mb-6">{product.description}</p>

        <AddToCartButton productId={product.id} />
      </div>
    </div>
  );
}

客户端组件与服务端数据交互

// components/AddToCartButton.tsx - 客户端组件
"use client";

import { useState } from "react";
import { useRouter } from "next/navigation";

// 服务器操作
import { addToCart } from "@/app/actions";

interface AddToCartButtonProps {
  productId: string;
}

export default function AddToCartButton({ productId }: AddToCartButtonProps) {
  const [quantity, setQuantity] = useState(1);
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();

  const handleAddToCart = async () => {
    setIsLoading(true);
    try {
      // 调用服务器操作
      await addToCart(productId, quantity);
      // 刷新服务器组件
      router.refresh();
      // 显示成功消息
      showSuccessToast("商品已添加到购物车");
    } catch (error) {
      console.error("添加到购物车失败", error);
      showErrorToast("添加到购物车失败,请重试");
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div className="space-y-4">
      <div className="flex items-center">
        <button
          onClick={() => setQuantity(Math.max(1, quantity - 1))}
          className="px-3 py-1 border rounded-l-md bg-gray-100"
          aria-label="减少数量"
        >
          -
        </button>
        <span className="px-4 py-1 border-t border-b text-center w-16">
          {quantity}
        </span>
        <button
          onClick={() => setQuantity(quantity + 1)}
          className="px-3 py-1 border rounded-r-md bg-gray-100"
          aria-label="增加数量"
        >
          +
        </button>
      </div>

      <button
        onClick={handleAddToCart}
        disabled={isLoading}
        className="w-full bg-blue-600 text-white py-3 px-6 rounded-md hover:bg-blue-700 transition disabled:opacity-70"
      >
        {isLoading ? "添加中..." : "添加到购物车"}
      </button>
    </div>
  );
}

// 显示消息的辅助函数
function showSuccessToast(message: string) {
  // 实现toast通知
}

function showErrorToast(message: string) {
  // 实现错误toast通知
}
// app/actions.ts - 服务端操作
"use server";

import { cookies } from "next/headers";
import { revalidatePath } from "next/cache";

// 服务器操作:添加到购物车
export async function addToCart(productId: string, quantity: number) {
  // 获取当前购物车
  const cookieStore = cookies();
  const cartCookie = cookieStore.get("cart");
  let cart = cartCookie ? JSON.parse(cartCookie.value) : [];

  // 查找商品是否已在购物车
  const existingItemIndex = cart.findIndex(
    (item: any) => item.productId === productId
  );

  if (existingItemIndex >= 0) {
    // 更新数量
    cart[existingItemIndex].quantity += quantity;
  } else {
    // 添加新商品
    cart.push({ productId, quantity });
  }

  // 保存购物车到cookie
  cookieStore.set("cart", JSON.stringify(cart), {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    maxAge: 60 * 60 * 24 * 7, // 一周
    path: "/",
  });

  // 重新验证购物车相关页面
  revalidatePath("/cart");
  revalidatePath("/products/[id]");

  return { success: true };
}

4. API 路由与全栈应用开发

Next.js 不只用于渲染页面,还可以作为 API 服务器:

// app/api/products/route.ts - API路由
import { NextResponse } from "next/server";
import { getProducts } from "@/lib/products";

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const category = searchParams.get("category");
  const page = parseInt(searchParams.get("page") || "1");
  const limit = parseInt(searchParams.get("limit") || "10");

  try {
    const products = await getProducts({ category, page, limit });

    return NextResponse.json({
      products: products.items,
      pagination: {
        total: products.total,
        page,
        limit,
        pages: Math.ceil(products.total / limit),
      },
    });
  } catch (error) {
    console.error("获取产品列表失败", error);
    return NextResponse.json({ error: "获取产品列表失败" }, { status: 500 });
  }
}
// app/api/products/[id]/route.ts - 动态API路由
import { NextResponse } from "next/server";
import { getProduct } from "@/lib/products";

export async function GET(
  request: Request,
  { params }: { params: { id: string } }
) {
  try {
    const product = await getProduct(params.id);

    if (!product) {
      return NextResponse.json({ error: "产品未找到" }, { status: 404 });
    }

    return NextResponse.json(product);
  } catch (error) {
    console.error("获取产品详情失败", error);
    return NextResponse.json({ error: "获取产品详情失败" }, { status: 500 });
  }
}

5. 数据获取与优化策略

Next.js 提供了强大的数据获取机制:

// lib/database.ts - 数据库连接
import { Pool } from "pg";
import { cache } from "react";

// 创建数据库连接池
const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  ssl:
    process.env.NODE_ENV === "production"
      ? { rejectUnauthorized: false }
      : false,
});

// 缓存数据库查询函数
export const queryDB = cache(async (text: string, params?: any[]) => {
  const client = await pool.connect();
  try {
    const result = await client.query(text, params);
    return result.rows;
  } finally {
    client.release();
  }
});
// app/dashboard/page.tsx - 带缓存控制的数据获取
import { Suspense } from "react";
import SalesChart from "@/components/SalesChart";
import ProductsTable from "@/components/ProductsTable";
import { getSalesData, getTopProducts } from "@/lib/analytics";

// 控制服务器端数据获取
export const dynamic = "force-dynamic"; // 或 'auto' | 'force-static'
export const revalidate = 3600; // 1小时重新验证

export default async function DashboardPage() {
  // 并行数据获取
  const [salesData, topProducts] = await Promise.all([
    getSalesData(),
    getTopProducts(),
  ]);

  return (
    <div className="space-y-8">
      <h1 className="text-3xl font-bold">仪表盘</h1>

      <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
        <div className="col-span-2 bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-4">销售趋势</h2>
          <Suspense fallback={<div>加载销售数据...</div>}>
            <SalesChart data={salesData} />
          </Suspense>
        </div>

        <div className="bg-white p-6 rounded-lg shadow">
          <h2 className="text-xl font-semibold mb-4">概览</h2>
          <dl className="space-y-4">
            <div>
              <dt className="text-gray-600">今日销售额</dt>
              <dd className="text-3xl font-bold">
                ¥{salesData.today.toLocaleString()}
              </dd>
            </div>
            <div>
              <dt className="text-gray-600">本月销售额</dt>
              <dd className="text-3xl font-bold">
                ¥{salesData.month.toLocaleString()}
              </dd>
            </div>
            <div>
              <dt className="text-gray-600">订单完成率</dt>
              <dd className="text-3xl font-bold">
                {salesData.completionRate}%
              </dd>
            </div>
          </dl>
        </div>
      </div>

      <div className="bg-white p-6 rounded-lg shadow">
        <h2 className="text-xl font-semibold mb-4">热门产品</h2>
        <Suspense fallback={<div>加载产品数据...</div>}>
          <ProductsTable products={topProducts} />
        </Suspense>
      </div>
    </div>
  );
}

6. 部署与性能优化

// next.config.js - 生产优化配置
/** @type {import('next').NextConfig} */
const nextConfig = {
  // 图像优化
  images: {
    domains: ["example.com", "cdn.example.com"],
    formats: ["image/avif", "image/webp"],
  },

  // 国际化
  i18n: {
    locales: ["zh-CN", "en-US"],
    defaultLocale: "zh-CN",
  },

  // 压缩优化
  compress: true,

  // 响应头
  async headers() {
    return [
      {
        source: "/:path*",
        headers: [
          {
            key: "Cache-Control",
            value:
              "public, max-age=3600, s-maxage=86400, stale-while-revalidate=604800",
          },
          {
            key: "X-Content-Type-Options",
            value: "nosniff",
          },
          {
            key: "X-Frame-Options",
            value: "DENY",
          },
          {
            key: "X-XSS-Protection",
            value: "1; mode=block",
          },
        ],
      },
    ];
  },

  // 重定向
  async redirects() {
    return [
      {
        source: "/old-blog/:slug",
        destination: "/blog/:slug",
        permanent: true,
      },
    ];
  },

  // 输出优化
  output: "standalone", // 适合容器化部署
};

module.exports = nextConfig;
# .github/workflows/deploy.yml - 部署工作流
name: Deploy Next.js

on:
  push:
    branches: [main]

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18
          cache: "npm"

      - name: Install dependencies
        run: npm ci

      - name: Build
        run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL }}
          API_KEY: ${{ secrets.API_KEY }}

      - name: Deploy to Vercel
        uses: amondnet/vercel-action@v20
        with:
          vercel-token: ${{ secrets.VERCEL_TOKEN }}
          vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
          vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
          vercel-args: "--prod"

下一篇预告:《【React 与微前端】大型应用的模块化与微服务化架构》

在系列的下一篇中,我们将探索如何处理大型企业级 React 应用的架构挑战:

  • 微前端架构的优势与实现方案
  • 前端微服务设计模式
  • 基于 Module Federation 的代码共享与独立部署
  • 微前端的性能优化与状态管理
  • 团队协作与开发效率的平衡

随着企业应用规模不断扩大,如何保持代码可维护性和团队并行开发效率成为关键问题。下一篇,我们将为你揭开构建高效大型前端应用的秘密!

敬请期待!

关于作者

Hi,我是 hyy,一位热爱技术的全栈开发者:

  • 🚀 专注 TypeScript 全栈开发,偏前端技术栈
  • 💼 多元工作背景(跨国企业、技术外包、创业公司)
  • 📝 掘金活跃技术作者
  • 🎵 电子音乐爱好者
  • 🎮 游戏玩家
  • 💻 技术分享达人

加入我们

欢迎加入前端技术交流圈,与 10000+开发者一起:

  • 探讨前端最新技术趋势
  • 解决开发难题
  • 分享职场经验
  • 获取优质学习资源

添加方式:掘金摸鱼沸点 👈 扫码进群

定制化 Docsify 文档框架实战分享

作者 布鲁斯wang
2025年4月18日 10:28

🌟 定制化 Docsify 文档框架实战分享

在构建前端文档平台时,我们希望拥有更友好的用户界面、便捷的搜索、清晰的目录导航以及实用的代码复制功能。借助 Docsify,我实现了以下几个方面的定制优化,分享给大家 🙌

📌 本文基于 Docsify + 插件实现,包括自定义 JavaScript 功能注入与样式优化。

🎨 UI 界面改造

仿 CSDN 布局模式及 UI,去除左侧菜单收展,左侧菜单只展示文档目录,右侧增加搜索及文档内容目录,整体看上去更简洁点,当然你可以随意改你喜欢的样式;

原始 UI 在这里插入图片描述

改造后 UI 在这里插入图片描述

🎫 版本控制

通过动态更改文档资源根路径实现文档版本切换;

配置:

 basePath: sessionStorage.getItem("version") || "/docs/v1",

HTML 实现:

<div class="versionBox">
  <label for="version">版本:</label>
  <select id="version" name="version" onchange="changeBasePath(this)">
    <option value="a">v1</option>
    <option value="b">v2</option>
  </select>
</div>

JS :

function changeBasePath(select) {
  var selectedValue = select.value;
  if (selectedValue === "a") {
    sessionStorage.setItem("version", "/docs/v1");
    sessionStorage.setItem("ver", "a");
  } else if (selectedValue === "b") {
    sessionStorage.setItem("version", "/docs/v2");
    sessionStorage.setItem("ver", "b");
  }

  window.location.reload();
}

🔍 搜索功能配置

通过引入 search.min.js 插件,并配置如下,实现了全文搜索功能,支持关键词高亮匹配与模糊查询。

配置代码:

search: {
  maxAge: 86400000, // 一天缓存
  paths: "auto",
  placeholder: "请输入关键词",
  noData: "未检索到相关内容!",
  depth: 2,
  hideOtherSidebarContent: false
}

📚 自定义浮动目录(锚点导航)

Docsify 默认只提供左侧文档目录,现需要实现一个右侧的文档内容导航目录,通过自定义插件读取文档内容生成目录。

实现逻辑包括:

  • 获取 .content 中所有 h1 ~ h3

  • 动态生成目录列表并缩进

  • 自动隐藏无标题页面目录

  • 修复锚点跳转偏移问题(插入空的

核心插件代码:

hook.doneEach(function () {
  var floatingToc = document.querySelector(".floating-toc");
  if (!floatingToc) {
    floatingToc = document.createElement("div");
    floatingToc.className = "floating-toc";
    document.body.appendChild(floatingToc);
    var tocHeader = document.createElement("h2");
    tocHeader.textContent = "目录";
    floatingToc.appendChild(tocHeader);
  }

  floatingToc.innerHTML = "<h2>目录</h2>";
  var currentUrl = window.location.href.split("?")[0];
  var content = document.querySelector(".content");

  if (content) {
    var headers = content.querySelectorAll("h1, h2, h3");
    if (headers.length > 1) {
      floatingToc.style.display = "block";
      var tocList = document.createElement("ul");
      floatingToc.appendChild(tocList);
      headers.forEach(function (header) {
        var li = document.createElement("li");
        var level = parseInt(header.tagName.substring(1)) - 1;
        li.style.marginLeft = level * 20 + "px";

        var anchorLink = document.createElement("a");
        anchorLink.href = currentUrl + "#" + header.id;
        anchorLink.textContent = header.textContent;
        li.appendChild(anchorLink);
        tocList.appendChild(li);
      });
    } else {
      floatingToc.style.display = "none";
    }

    var anchors = document.querySelectorAll("h2[id],h3[id],h4[id]");
    anchors.forEach(function (anchor) {
      var fixAnchorDiv = document.createElement("div");
      fixAnchorDiv.className = "fix-anchor";
      fixAnchorDiv.id = anchor.id;
      anchor.parentNode.insertBefore(fixAnchorDiv, anchor);
      anchor.id = "";
    });
  } else {
    floatingToc.style.display = "none";
  }
});

📋 代码复制功能

我们使用了 docsify-copy-code 插件,给所有代码块添加“点击复制”按钮。

引入插件:

<script src="//cdn.jsdelivr.net/npm/docsify-copy-code/dist/docsify-copy-code.min.js"></script>

配置:

copyCode: {
buttonText: "点击复制",
errorText: "Error",
successText: "复制成功"
}

✅ 总结

通过简单的 HTML + JS + 插件组合,我们在原始 Docsify 的基础上实现了以下功能:

  • 多版本切换支持

  • 搜索功能优化

  • 浮动目录导航

  • 一键复制代码

  • UI 更加美观清晰

🧑‍💻 后续计划

  • 🌐 支持多语言国际化(i18n)

  • 🌗 主题切换(深色 / 浅色模式)

  • 🚀 与 GitHub Actions 等 CI/CD 集成自动部署

源码地址

github.com/Bing-b/docs

Vue3使用AntvG6写拓扑图,可添加修改删除节点和边

作者 琦遇
2025年4月18日 10:07

npm安装antv/g6

npm install @antv/g6 --save

上代码

<template>
    <div id="tpt1" ref="container" style="width: 100%;height: 100%;"></div>
</template>

<script setup>
    import { Renderer as SVGRenderer } from '@antv/g-svg';//通过svg方式呈现
    import { Graph,iconfont } from '@antv/g6';
    import { onMounted, onUnmounted, ref,nextTick,computed } from 'vue';
    const container = ref();
    const nodes = ref([
        { id: '1', style: { x: 550, y: 100 },data:{id:'1',name:"wifi路由器",type:"路由器"} }//数据格式
    ])
    const edges = ref([
        { id:'1-3-g1',source: '1', target: '3',data:{id:1,name:"线路1",status:0,outRate:"80MB/s",inRate:"50MB/s",scoureName:"url",scoureType:"",scoureUrl:"",targetName:"http",targetType:"",targetUrl:"",watchDevice:"",watchUrl:""} }//数据格式
    ])
    let graph:any = null
    
    onMounted(() => {
        updateChart();
    })
    onUnmounted(() => {
        graph && graph.clear();
    });
    //获取所有节点
    const getNodeData = () => {
        return graph.getNodeData()
    }
    //获取所有边
    const getEdgeData = () => {
        return graph.getEdgeData()
    }
    //获取缩放比例
    const getZoom = () => {
        return graph.getZoom();
    }
    const updateChart = () => {
        const style = document.createElement('style');
        style.innerHTML = `@import url(${iconfont.css});`;
        document.head.appendChild(style);
        graph = new Graph({
            container: container.value,
            data: {
                nodes: nodes.value,
                edges: edges.value,
            },
            edge: {
                // type:"extra-label-edge",
                style: {//线条的样式
                    cursor: 'pointer',
                    lineWidth: 1,
                    labelText: (d:any) => {
                        // 改变边上的第一行第二行颜色
                        nextTick(()=>{
                            let parentElement = document.getElementById(d.id)
                            let tspanList = parentElement.getElementsByTagName("tspan")
                            setTimeout(()=>{
                                if(tspanList.length>=2){
                                    tspanList[0].setAttribute("fill","#81f4f9")
                                    tspanList[1].setAttribute("fill","#eca13c")
                                }
                            })
                        })
                        return props.isEdit||(d.data.outRate==''&&d.data.inRate=='')?'':`⬆${d.data.outRate}\n⬇${d.data.inRate}`;
                    },
                    data:(d)=>{
                            return d.data
                    },
                    endArrow: props.isEdit?true:false,
                    endArrowType: (d) => d.id.split('-')[0],
                    increasedLineWidthForHitTesting:10,
                    stroke: '#83d6dc',
                    labelAutoRotate:true,//是否旋转与边一致
                    labelFill: '#fff',
                    labelFontSize: 11,
                    labelPadding:[3,7],
                    zIndex:2
                },
            },
            transforms: [
                {
                    type:"process-parallel-edges",
                    mode:"bundle",
                    distance: 50
                }
            ],
            node: {
                type: 'image',
                style:{
                    padding:[10,10],
                    size: [60,60],
                    labelText: (d:any) => {
                        return d.data.name;
                    },
                    src: (d)=>{
                        //通过类型自定义节点图片
                        let imgArr:any = {
                            "交换机":"tpt_jhj.png",
                            "路由器":"tpt_lyq.png",
                            "安全设备":"tpt_fhq.png",
                            "其他设备":"tpt_qt.png",
                        }
                        return (d.data.type?getImageUrl(imgArr[d.data.type]):getImageUrl('tpt_qt.png')) || getImageUrl('tpt_qt.png')
                    },
                    data:(d)=>{
                            return d.data
                    },
                    labelPosition: 'bottom',
                    labelFill: '#fff',
                    labelFontSize: 13,
                    labelBackground: false,//背景颜色
                    labelBackgroundFill: 'linear-gradient(#e66465, #9198e5)',
                    labelBackgroundStroke: '#9ec9ff',
                    labelBackgroundRadius: 2,
                    labelFontWeight: 600,
                    labelPadding:[3,10],
                    zIndex:3,
                    labelOffsetY:8,
                    badge: false, // 是否显示徽标
                    badges: [
                        { text: 'x', placement: 'right-top',padding:[2,5] },
                    ],
                    badgePalette: ['red'], // 徽标的背景色板
                    badgeFontSize: 10, // 徽标字体大小
                }
            },
            behaviors: [
                {
                    type:'zoom-canvas'//缩放
                },
                {
                    type: 'drag-canvas',
                    key: 'drag-canvas-1',
                },
                {
                    type:"drag-element",
                    key: 'drag-element-1',
                    enableAnimation:false,
                    shadow:false//拖动样式
                },
                {
                    type: 'create-edge',
                    key:"create-edge-1",
                    trigger: 'click',//drag
                    onCreate: (edge) => {//创建线的样式
                        const { style, ...rest } = edge;
                        return {
                            ...rest,
                            data:{
                                name:"",
                                status:0,
                                scoureName:"",
                                scoureType:"",
                                scoureUrl:"",
                                targetName:"",
                                targetType:"",
                                targetUrl:"",
                                watchDevice:"",
                                watchUrl:"",
                                inRate:"",
                                outRate:"",
                            },
                            style: {
                                ...style,
                                stroke: 'red',
                                lineWidth: 2,
                                endArrow: true,
                            },
                        };
                    },
                },
            ],
            renderer: () => new SVGRenderer(),
        });
        graph.render();
        //鼠标右键点击节点编辑
        graph.on('node:contextmenu', (e) => {
            //添加编辑设备不为0是修改
            form.value = e.target.config.style.data
            submitNode();
        });
    }
    //添加设备按钮
    const addNode = () => {
        form.value.id = 0
        submitNode();
    }
    //添加修改节点
    const submitNode = () => {
        if(form.value.id == 0){
            //添加
            nodes.value = graph.getNodeData();
            graph.addData({
                nodes:[{
                    id: nodes.value.length>0?`${graph.getNodeData().length+1}`:"1",
                    style:{ x: container.value.clientWidth/2, y: 30 },
                    data:{id:nodes.value.length>0?`${graph.getNodeData().length+1}`:"1",name:form.value.name,type:form.value.type}
                }]
            })
        }else{
            graph.updateNodeData([{id:form.value.id,data:form.value}])
        }
        graph.render();
    }
</script>

运行结果

微信截图_20250418094824.png

不断更新

🗣️面试官:有一个比较经典的 case 也想探讨一下 「页面白屏如何排查?」❤️✨

作者 Luckyfif
2025年4月18日 10:05

前段时间面试字节,面试官对我的埋点项目比较感兴趣,聊着聊着突然来了一个问题(原话):既然说到了这样一个叫做监控,其实有一个比较经典的 case 也想探讨一下,就是比如说你有在开发过程中,假如你的页面被反馈说白屏了,对吧?正常情况下你要去排查问题、解决问题,那我想让你就是简单的描述一下你会去怎么去做,就是把你要准备处理的过程也描述一下。

我的回答:有提到检查 DOM 挂载 / 网络请求状态 / JS 执行错误,但未覆盖资源加载失败CDN 异常 等场景。虽然有简单了解过页面白屏相关的内容,不过还没在实习工作中遇到过这类问题,还是缺少了些系统性排查的思维,这篇文章就来总结学习一下。(先了解页面白屏原因 -> 梳理一下排查思路 -> 学习下如何在SDK中实现白屏检测)

一、先来总览一下,什么是页面白屏?

  • 前端白屏是指用户打开网页时,页面未能正常加载或渲染,导致浏览器显示一片空白。
  • 一般情况下 是由 JS执行错误 / 资源加载失败 / 网络问题 / 渲染逻辑错误 引起的。
  • 在单页面应用中(SPA),前端白屏问题会变得更加复杂,可能导致用户无法看到任何有效内容。
  • 而解决白屏问题的关键是:快速定位并修复错误,确保资源正确加载和渲染。

白屏问题本质上是浏览器渲染流水线的断裂,从 DNS 解析 -> 资源加载 -> JS 执行 -> DOM 构建 -> 渲染树生成 -> 页面绘制的完整链路中,任一环节的异常都可能导致最终呈现的空白。

二、再来系统性梳理一下排查思路

Luckyfif原创

图为原创, 若需转载 可以备注出处✨

看完这么复杂的排查流程, 来思考下一个页面白屏 真的值得如此认真对待吗? (问问那些大厂c端的大佬们就知道了)

用户体量越大, 页面白屏时间能带来的负面影响就越能呈现指数级增长, 比如说:

  • 业务层面:电商场景下每增加1秒白屏时间转化率下降7%
  • 技术层面:可能引发雪崩效应(如 CDN 故障导致全站不可用)
  • 体验层面:用户留存率下降40%+

1. 第一阶段:快速定位问题层级

浏览器控制台四步诊断法

// Step 1 - 检测文档加载阶段
console.log('DOMContentLoaded:', performance.timing.domContentLoadedEventEnd - performance.timing.navigationStart);
console.log('Load Event:', performance.timing.loadEventEnd - performance.timing.navigationStart);

// Step 2 - 检查关键错误
window.addEventListener('error', e => {
  console.error('Global Error:', e.message, e.filename, e.lineno);
}, true);

// Step 3 - 验证 DOM 挂载点(React/Vue 重点)
const rootNode = document.getElementById('root');
if (!rootNode || rootNode.childNodes.length === 0) {
  console.error('挂载节点异常:', rootNode);
}

// Step 4 - 网络状态检测
fetch('/health-check').catch(e => {
  console.error('网络连通性异常:', e);
});

典型问题场景

  • Vue/React 未捕获的初始化错误导致 root 节点为空
  • 浏览器插件注入的脚本引发全局错误

2. 第二阶段:网络层深度检测

(1)关键资源瀑布流分析

使用 Chrome DevTools 的 Network 面板

  1. 过滤 JS|CSS|IMG 类型资源

  2. 检查关键资源的:

    • HTTP 状态码(重点 404/403/500)
    • Timing 明细(TTFB 是否异常)
  3. 右键资源 → Copy as cURL 验证 CDN 可用性

(2)CDN 故障专项排查

# 多节点探测(需安装 httpie)
http https://cdn.example.com/main.js --verify=no \
  --headers \ 
  --proxy=http:http://1.1.1.1:8080 \  # 切换不同代理节点
  --download > /dev/null

# DNS 污染检测
nslookup cdn.example.com 8.8.8.8   # 对比不同 DNS 结果
nslookup cdn.example.com 114.114.114.114

经典案例
某站点因 CDN 节点未同步最新证书,导致部分用户浏览器拦截 HTTPS 请求引发白屏

(3)资源完整性校验(SRI 实战)

<!-- 带 SRI 校验的资源加载 -->
<script src="https://cdn.example.com/react.production.min.js" 
        integrity="sha384-xxxx"
        crossorigin="anonymous"></script>

排查要点

  • 控制台出现 Integrity checksum failed 错误
  • 比对服务器资源 hash 值:
openssl dgst -sha384 -binary react.production.min.js | openssl base64 -A

3. 第三阶段:渲染层故障定位

(1)SPA 框架特有陷阱

Vue 场景

new Vue({
  render: h => h(App)
}).$mount('#app')  // 若 #app 节点不存在,静默失败!

解决方案

const root = document.getElementById('app');
if (!root) {
  document.write('容器丢失,降级显示基础内容'); 
} else {
  new Vue({ render: h => h(App) }).$mount(root);
}

React 场景

// 错误边界组件(捕获渲染层错误)
class ErrorBoundary extends React.Component {
  componentDidCatch(error) {
    Sentry.captureException(error);
    window.location.reload();  // 降级策略
  }
  render() { return this.props.children; }
}

// 使用方式
<ErrorBoundary>
  <App />
</ErrorBoundary>

(2)CSS 渲染阻塞

检测方法

  1. 浏览器地址栏输入 about:blank 清空页面
  2. 逐步加载 CSS 文件,观察布局变化
  3. 检查 z-index 异常导致元素不可见

典型案例
某页面因 body { display: none !important; } 内联样式导致白屏

4. 第四阶段:性能维度深度分析

(1)主线程阻塞检测

Long Tasks API

const observer = new PerformanceObserver(list => {
  list.getEntries().forEach(entry => {
    if (entry.duration > 50) {
      console.warn('主线程阻塞:', entry);
    }
  });
});
observer.observe({ entryTypes: ['longtask'] });

(2)内存泄漏追踪

Chrome Memory 面板操作

  1. 生成堆快照(Heap Snapshot)
  2. 筛选 Detached DOM tree 检查未释放节点
  3. 对比多次快照,查找持续增长的对象

典型案例
未销毁的 WebSocket 监听器持续累积导致内存溢出

(3)关键指标阈值

指标 警告阈值 严重阈值 测量工具
FCP >2s >4s Lighthouse
JS 总执行时间 >3s >5s Chrome Performance 面板
未压缩资源占比 >30% >50% Webpack Bundle Analyzer

5. 第五阶段:环境特异性问题

(1)浏览器兼容性

// 使用 Feature Detection 代替 UA 检测
if (!('IntersectionObserver' in window)) {
  loadPolyfill('intersection-observer').then(initApp);
}

(2)运营商劫持检测

// 检查页面是否被注入第三方脚本
const thirdPartyScripts = Array.from(document.scripts).filter(
  s => !s.src.includes(window.location.hostname)
);
if (thirdPartyScripts.length > 0) {
  reportException('运营商劫持', thirdPartyScripts);
}

(3)本地环境干扰

  • 禁用所有浏览器插件(尤其是广告拦截器)
  • 清除 Service Worker 缓存:
navigator.serviceWorker.getRegistrations().then(regs => {
  regs.forEach(reg => reg.unregister())
})

(4)兜底策略

  • 用户操作视频录制(接入rrweb等工具, 大公司监控体系下一般都有用到, 没有用上的建议也可以加上)
  • 特定设备远程调试(使用Chrome Remote Debugging)

三、白屏检测 SDK 要怎么写?

这里就给大家放部分最近写的代码, 主要用的是动态检测根节点+黄金比例采样算法+采样点检测三种方法来检测白屏情况, 感兴趣的也可以去我的代码仓库看下完整的代码 - ByteTop - 轻量级Web端埋点监控平台

核心代码

1. 智能根节点检测

const rootSelectors = ["#root", "#app", "#main", "#container"];
const rootNode = rootSelectors.find(selector => 
  document.querySelector(selector)
) || "body";
const wrapperSet = new Set(["html", "body", rootNode.toLowerCase()]);
  • 策略:优先级遍历常见框架挂载点选择器(#root → #app → #main → ...)
  • 降级:未匹配时自动降级到 body 元素
  • 优化:使用 Set 数据结构实现 O(1) 复杂度查询

2. 黄金比例采样算法

const goldenRatio = 0.618;
const points = Array.from({ length: config.sampleCount }, (_, i) => ({
  x: i % 2 === 0 
    ? window.innerWidth * goldenRatio * Math.random()
    : window.innerWidth - window.innerWidth * goldenRatio * Math.random(),
  y: window.innerHeight * goldenRatio * Math.random()
}));
  • 视觉聚焦:61.8% 区域密集采样,符合人类视觉焦点分布规律
  • 抗对称干扰:通过奇偶索引实现左右镜像分布,破解居中布局误判
  • 随机扰动:在黄金比例区域内引入随机坐标,避免固定路径采样

3. 复合特征检测

const identifiers = [
  element.tagName.toLowerCase(),          // 标签特征
  element.id ? `#${element.id}` : "",    // ID 特征
  ...Array.from(element.classList).map(c => `.${c}`) // 类名特征
];

if (identifiers.some(id => wrapperSet.has(id))) {
  emptyCount++;
}
  • 三级特征提取:标签名、ID、类名全方位标识元素
  • 动态类名支持:兼容 CSS Modules 等哈希类名场景
  • 高效匹配:Set 数据结构实现快速特征比对

4. 动态阈值策略

return emptyCount / config.sampleCount >= config.threshold;
  • 比例控制:通过阈值参数控制误报率与漏报率的平衡
  • 场景适配:移动端推荐 0.7-0.8,PC 端推荐 0.8-0.9
  • 动态感知:根据设备类型自动调节阈值(需扩展实现)

完整代码

interface CheckWhiteScreenOptions {
  /** 采样点数量 (默认: 20) */
  sampleCount?: number;
  /** 空白点判定阈值 (0-1, 默认 0.8) */
  threshold?: number;
  /** 排除的骨架屏类名 (默认: 'skeleton') */
  skeletonClass?: string;
}
const checkWhiteScreen = (options?: CheckWhiteScreenOptions): boolean => {
  const config = {
    sampleCount: 20,
    threshold: 0.8,
    skeletonClass: "skeleton",
    ...options,
  };
  try {
    // 1. 排除骨架屏场景
    if (document.getElementsByClassName(config.skeletonClass).length > 0) {
      return false;
    }  

    // 2. 动态检测根节点
    const rootSelectors = ["#root", "#app", "#main", "#container"];
    const rootNode =
      rootSelectors.find((selector) => document.querySelector(selector)) ||
      "body";
    const wrapperSet = new Set(["html", "body", rootNode.toLowerCase()]);
    
    // 3. 黄金比例采样算法
    const goldenRatio = 0.618;
    const points = Array.from({ length: config.sampleCount }, (_, i) => ({
      x:
        i % 2 === 0
          ? window.innerWidth * goldenRatio * Math.random()
          : window.innerWidth - window.innerWidth * goldenRatio * Math.random(),
      y: window.innerHeight * goldenRatio * Math.random(),
    }));  

    // 4. 采样点检测
    let emptyCount = 0;
    points.forEach((point) => {
      const element = document.elementFromPoint(point.x, point.y);
      if (!element) {
        emptyCount++;
        return;
      }
      const identifiers = [
        element.tagName.toLowerCase(),
        element.id ? `#${element.id}` : "",
        ...Array.from(element.classList).map((c) => `.${c}`),
      ];
      if (identifiers.some((id) => wrapperSet.has(id))) {
        emptyCount++;
      }
    });
    console.log("emptyCount", emptyCount, " config:", config);
    
    // 5. 阈值判断
    return emptyCount / config.sampleCount >= config.threshold;
  } catch (e) {
    console.error("[白屏检测异常]", e);
    return false;
  }
};

export default checkWhiteScreen;
// // 自定义配置
// const isWhite = checkWhiteScreen({
//   sampleCount: 30,
//   threshold: 0.75,
//   skeletonClass: 'loading-skeleton'
// });

// // 移动端适配配置

// checkWhiteScreen({
//   sampleCount: 15,  // 减少采样点
//   threshold: 0.7    // 降低阈值
// });

// // 后台管理系统
// checkWhiteScreen({
//   skeletonClass: 'ant-skeleton' // 匹配UI框架
// });

// // 高精度检测
// checkWhiteScreen({
//   sampleCount: 50,  // 增加采样密度
//   threshold: 0.9    // 严格判定
// });

图片.png

这里的上报功能是其他代码实现的, 都放在 github 里面了😊

总结

终于写完了, 最后的最后, 我这里想说: 从一月份开始做埋点监控相关的项目, 很难但也很有意思, 边实习边抽空做自己的项目, 好在面试过程中这个项目也为我加了很多分, 大家感兴趣的话可以去点个star(虽然目前是开源的, 但不确定后续会不会短暂闭源, 点了star就可以获得永久保存下来), 后续还会利用空闲时间继续完善( 自建服务端+可视化分析平台+SDK ) ByteTop - 轻量级Web端埋点监控平台 名字瞎起的, 还请大佬们勿喷😭

❌
❌