阅读视图

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

LeetCode 题解 | 1.两数之和(最优解)

题目——1.两数之和

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target  的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案,并且你不能使用两次相同的元素。

你可以按任意顺序返回答案。

示例 1:

输入: nums = [2,7,11,15], target = 9
输出: [0,1]
解释: 因为 nums[0] + nums[1] == 9 ,返回 [0, 1]

示例 2:

输入: nums = [3,2,4], target = 6
输出: [1,2]

示例 3:

输入: nums = [3,3], target = 6
输出: [0,1]

 

提示:

  • 2 <= nums.length <= 104
  • -109 <= nums[i] <= 109
  • -109 <= target <= 109
  • 只会存在一个有效答案

二、小白题解(正是本人)

/**

 * @param {number[]nums

 * @param {numbertarget

 * @return {number[]}

 */

var twoSum = function(nums, target) {

    for(let i=0;i<nums.length;i++){

        for(let j=i+1;j<nums.length;j++){

            if(nums[i]+nums[j]===target){

                return [i,j];

            }

        }

    }

};

这个解题思路有很多缺点—— 时间复杂度高没有异常处理看上去像作者自己写的......

总之就是一个暴力题解,我们在最坏的情况下是N(O2)N(O2)的时间复杂度,会将nums中的所有元素组合遍历知道找到符合条件的两个元素的下标,那么有没有更好的解法呢?

三、有的兄弟,肯定有的(时间复杂度上的最优解)

/**
 * @param {number[]} nums
 * @param {number} target
 * @return {number[]}
 */
function twoSum(nums, target) {
    const numMap = {};
    for (let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if (complement in numMap) {
            return [numMap[complement], i];
        }
        numMap[nums[i]] = i;
    }
    return []; 
}

这个解法用到了哈希表,这里我们并不需要去详细研究哈希表是个什么东西,我们这里可以把哈希表理解为一个容器,里面的数据可以拿出来和我们想要的目标数据对比,每次对比是N(O)N(O)的复杂度。

我们来看题解的主体

 for (let i = 0; i < nums.length; i++) {
        const complement = target - nums[i];
        if (complement in numMap) {
            return [numMap[complement], i];
        }
        numMap[nums[i]] = i;
    }

1.每个元素遍历了几次?

我们只有一个for循环,这意味着每个数组里面的元素只会遍历一遍

2.每个元素遍历经历了什么

存入哈希表之前

1.被用作计算和自己组合起来符合题意的元素的值

const complement = target - nums[i];

2.被存储在哈希表中作为键值存储在哈希表中,同时下表作为对应键值的值

numMap[nums[i]] = i;

存入哈希表之后

3.作为可被寻找的、其他元素的“另一半”,如果确定是,则输出两元素在原数组的下标

 if (complement in numMap) {
            return [numMap[complement], i];
        }

四、下面我们用一个例子来分析最优解的执行过程

nums = [2,7,11,15];
target = 9

具体过程如下(图解)

image.png

image.png

image.png

image.png

五、可执行流程(将代码复杂到编译器运行即可得到动态图解)

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>两数之和 - 哈希表解法示意图</title>
    <style>
        body {
            font-family: 'Arial', sans-serif;
            max-width: 800px;
            margin: 0 auto;
            padding: 20px;
            background-color: #f5f7fa;
        }
        .container {
            background: white;
            border-radius: 10px;
            padding: 20px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        }
        h1 {
            color: #2c3e50;
            text-align: center;
        }
        .visualization {
            display: flex;
            flex-direction: column;
            gap: 20px;
            margin-top: 30px;
        }
        .array-container {
            display: flex;
            justify-content: center;
            position: relative;
            height: 100px;
        }
        .array-element {
            width: 60px;
            height: 60px;
            background: #3498db;
            color: white;
            display: flex;
            align-items: center;
            justify-content: center;
            border-radius: 5px;
            margin: 0 5px;
            position: relative;
            transition: all 0.3s;
            font-weight: bold;
        }
        .array-index {
            position: absolute;
            top: -20px;
            color: #7f8c8d;
            font-size: 12px;
        }
        .hash-table {
            display: flex;
            flex-wrap: wrap;
            gap: 10px;
            justify-content: center;
            min-height: 120px;
            background: #ecf0f1;
            padding: 15px;
            border-radius: 5px;
        }
        .hash-entry {
            background: #2ecc71;
            color: white;
            padding: 8px 12px;
            border-radius: 5px;
            display: flex;
            flex-direction: column;
            align-items: center;
            opacity: 0;
            transform: scale(0.8);
            transition: all 0.5s;
        }
        .hash-entry.visible {
            opacity: 1;
            transform: scale(1);
        }
        .hash-key {
            font-weight: bold;
            border-bottom: 1px solid white;
            margin-bottom: 3px;
            padding-bottom: 3px;
        }
        .current {
            box-shadow: 0 0 0 3px #e74c3c;
            transform: scale(1.1);
        }
        .complement {
            background: #e74c3c;
        }
        .found {
            background: #f39c12;
            animation: pulse 0.5s 2;
        }
        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.1); }
            100% { transform: scale(1); }
        }
        .explanation {
            background: #f8f9fa;
            padding: 15px;
            border-radius: 5px;
            margin-top: 20px;
            border-left: 4px solid #3498db;
        }
        .controls {
            display: flex;
            justify-content: center;
            gap: 10px;
            margin-top: 20px;
        }
        button {
            padding: 8px 15px;
            background: #3498db;
            color: white;
            border: none;
            border-radius: 5px;
            cursor: pointer;
            transition: background 0.3s;
        }
        button:hover {
            background: #2980b9;
        }
    </style>
</head>
<body>
    <div class="container">
        <h1>两数之和 - 哈希表解法示意图</h1>
        <div class="visualization">
            <div class="array-container">
                <!-- 数组元素将通过JS动态生成 -->
            </div>
            <div class="hash-table">
                <!-- 哈希表内容将通过JS动态生成 -->
            </div>
        </div>
        <div class="explanation">
            <p><strong>当前步骤说明:</strong> <span id="step-explanation">初始化数组和哈希表</span></p>
            <p><strong>算法原理:</strong> 遍历数组,对于每个元素计算 complement = target - nums[i],检查 complement 是否存在于哈希表中。</p>
        </div>
        <div class="controls">
            <button id="prev-btn">上一步</button>
            <button id="next-btn">下一步</button>
            <button id="reset-btn">重置</button>
        </div>
    </div>

    <script>
        // 示例数据
        const nums = [2, 7, 11, 15];
        const target = 9;
        let currentStep = 0;
        let hashMap = {};
        
        // 初始化可视化
        function initVisualization() {
            const arrayContainer = document.querySelector('.array-container');
            arrayContainer.innerHTML = '';
            
            // 创建数组元素
            nums.forEach((num, index) => {
                const element = document.createElement('div');
                element.className = 'array-element';
                element.textContent = num;
                element.id = `num-${index}`;
                
                const indexLabel = document.createElement('div');
                indexLabel.className = 'array-index';
                indexLabel.textContent = index;
                
                element.appendChild(indexLabel);
                arrayContainer.appendChild(element);
            });
            
            updateHashTable();
            updateExplanation();
        }
        
        // 更新哈希表显示
        function updateHashTable() {
            const hashTable = document.querySelector('.hash-table');
            hashTable.innerHTML = '';
            
            for (const [key, value] of Object.entries(hashMap)) {
                const entry = document.createElement('div');
                entry.className = 'hash-entry visible';
                entry.innerHTML = `
                    <div class="hash-key">${key}</div>
                    <div>→ ${value}</div>
                `;
                hashTable.appendChild(entry);
            }
        }
        
        // 更新步骤说明
        function updateExplanation() {
            const explanation = document.getElementById('step-explanation');
            const currentElement = document.getElementById(`num-${currentStep}`);
            const elements = document.querySelectorAll('.array-element');
            
            // 重置所有元素样式
            elements.forEach(el => {
                el.classList.remove('current', 'complement', 'found');
            });
            
            switch(currentStep) {
                case 0:
                    explanation.textContent = "开始遍历数组,当前元素 nums[0] = 2";
                    currentElement.classList.add('current');
                    break;
                case 1:
                    explanation.textContent = "计算 complement = 9 - 2 = 7,7 不在哈希表中,将 2 存入哈希表";
                    currentElement.classList.add('current');
                    break;
                case 2:
                    explanation.textContent = "移动到 nums[1] = 7,计算 complement = 9 - 7 = 2";
                    currentElement.classList.add('current');
                    document.getElementById('num-0').classList.add('complement');
                    break;
                case 3:
                    explanation.textContent = "发现 2 在哈希表中(索引 0),找到解 [0, 1]!";
                    currentElement.classList.add('current');
                    document.getElementById('num-0').classList.add('found');
                    currentElement.classList.add('found');
                    break;
                default:
                    explanation.textContent = "遍历完成";
            }
        }
        
        // 下一步
        function nextStep() {
            if (currentStep >= 4) return;
            
            switch(currentStep) {
                case 0:
                    // 准备处理第一个元素
                    break;
                case 1:
                    // 处理 nums[0] = 2
                    hashMap[nums[0]] = 0;
                    break;
                case 2:
                    // 处理 nums[1] = 7
                    const complement = target - nums[1];
                    if (complement in hashMap) {
                        // 找到解的情况
                    }
                    break;
                case 3:
                    // 完成
                    break;
            }
            
            currentStep++;
            updateHashTable();
            updateExplanation();
        }
        
        // 上一步
        function prevStep() {
            if (currentStep <= 0) return;
            
            currentStep--;
            
            // 回退哈希表状态
            if (currentStep < 1) {
                hashMap = {};
            } else if (currentStep < 2) {
                hashMap = { [nums[0]]: 0 };
            }
            
            updateHashTable();
            updateExplanation();
        }
        
        // 重置
        function reset() {
            currentStep = 0;
            hashMap = {};
            initVisualization();
        }
        
        // 事件监听
        document.getElementById('next-btn').addEventListener('click', nextStep);
        document.getElementById('prev-btn').addEventListener('click', prevStep);
        document.getElementById('reset-btn').addEventListener('click', reset);
        
        // 初始化
        initVisualization();
    </script>
</body>
</html>

六、结语

再见!

Echarts-Vue3-多图表联动

图表组件使用文档

目录

组件概述

本文档介绍了基于 Vue 3 和 ECharts 开发的三个图表组件:

  1. ChartItem - 基础图表组件,用于渲染单个图表
  2. ChartGroup - 图表组组件,用于管理多个图表并提供联动功能
  3. ChartDemo - 示例组件,展示了如何使用 ChartGroup 和 ChartItem 创建复杂的图表展示

这些组件设计用于创建交互式、响应式的数据可视化界面,特别适合展示模型评估、数据分析等场景。

Snipaste_2025-04-17_17-22-34.jpg

Snipaste_2025-04-17_17-21-21.jpg

组件架构

这三个组件形成了一个层次化的结构:

ChartDemo
   └── ChartGroup
         ├── ChartItem (图表1)
         ├── ChartItem (图表2)
         └── ChartItem (图表3)
  • ChartDemo 作为容器组件,负责整体布局和控制逻辑
  • ChartGroup 管理多个 ChartItem 组件,提供图表联动和自动轮播功能
  • ChartItem 负责单个图表的渲染和基本交互

ChartItem 组件

功能特点

  • 支持多种图表类型(折线图、柱状图、饼图、散点图)
  • 自动响应窗口大小变化
  • 支持自定义配置和样式
  • 与 ChartGroup 组件无缝集成

属性说明

属性名 类型 默认值 说明
type 'line' | 'bar' | 'pie' | 'scatter' 'line' 图表类型
title string - 图表标题
xAxisData any[] [] X轴数据
series any[] [] 系列数据
height string '21rem' 图表高度
options object {} 自定义 ECharts 配置

使用示例

<template>
  <ChartItem
    type="line"
    title="月度收入趋势"
    :xAxisData="['1月', '2月', '3月', '4月', '5月', '6月']"
    :series="[
      {
        name: '无储',
        data: [150, 230, 224, 218, 135, 147],
        smooth: true,
      },
      {
        name: '传统充放',
        data: [180, 220, 240, 250, 170, 180],
        smooth: true,
      }
    ]"
    height="300px"
  />
</template>

<script setup>
import ChartItem from '@/components/ChartItem.vue';
</script>

ChartGroup 组件

功能特点

  • 管理多个 ChartItem 组件
  • 提供图表联动功能
  • 支持自动轮播 Tooltip
  • 支持鼠标悬停暂停
  • 支持数据缩放同步

属性说明

属性名 类型 默认值 说明
autoTooltip boolean false 是否开启自动 Tooltip 展示
tooltipDuration number 2000 Tooltip 展示时长(毫秒)
tooltipInterval number 3000 Tooltip 切换间隔(毫秒)
tooltipLoopDelay number 1000 一轮结束后延迟多久再次开始(毫秒)
pauseOnHover boolean true 鼠标移入时是否暂停

方法说明

方法名 参数 说明
getChartInstances - 获取所有图表实例
reconnect - 重新连接所有图表
toggleAutoTooltip enabled: boolean 切换自动 Tooltip 状态
startTooltipTimer - 启动 Tooltip 轮播
stopTooltipTimer hideTooltips?: boolean 停止 Tooltip 轮播
restartTooltipTimer - 重启 Tooltip 轮播
isHovering - 获取鼠标悬停状态

使用示例

<template>
  <ChartGroup
    ref="chartGroupRef"
    :auto-tooltip="true"
    :tooltip-interval="3000"
    :tooltip-duration="2000"
    :tooltip-loop-delay="2000"
    :pause-on-hover="true"
  >
    <ChartItem
      type="line"
      title="图表1"
      :xAxisData="xAxisData"
      :series="series1"
      height="300px"
    />
    <ChartItem
      type="bar"
      title="图表2"
      :xAxisData="xAxisData"
      :series="series2"
      height="300px"
    />
  </ChartGroup>
</template>

<script setup>
import { ref } from 'vue';
import ChartGroup from '@/components/ChartGroup.vue';
import ChartItem from '@/components/ChartItem.vue';

const chartGroupRef = ref(null);
const xAxisData = ['1月', '2月', '3月', '4月', '5月', '6月'];
const series1 = [/* ... */];
const series2 = [/* ... */];

// 控制自动轮播
const toggleAutoTooltip = (enabled) => {
  chartGroupRef.value?.toggleAutoTooltip(enabled);
};
</script>

ChartDemo 组件

功能特点

  • 展示完整的图表应用场景
  • 提供 Tooltip 控制面板
  • 支持多种图表布局
  • 响应式设计

使用示例

<template>
  <div class="chart-demo">
    <h2>模型评估图表</h2>

    <div class="controls-wrapper bg-white rounded-md shadow-sm p-3 mb-4 flex items-center gap-4">
      <a-switch
        v-model:checked="tooltipEnabled"
        checked-children="自动提示"
        un-checked-children="手动提示"
        @change="handleTooltipChange"
      />
      <div class="flex items-center gap-2">
        <span class="text-gray-500 text-sm">轮播间隔:</span>
        <a-tooltip title="自动轮播间隔时间(毫秒)">
          <a-input-number
            v-model:value="tooltipInterval"
            :min="1000"
            :max="10000"
            :step="500"
            size="small"
            :disabled="!tooltipEnabled"
            @change="handleIntervalChange"
          />
        </a-tooltip>
      </div>
      <a-checkbox v-model:checked="hoverPause" :disabled="!tooltipEnabled">悬停暂停</a-checkbox>
    </div>

    <ChartGroup
      ref="chartGroupRef"
      :auto-tooltip="tooltipEnabled"
      :tooltip-interval="tooltipInterval"
      :tooltip-duration="2000"
      :tooltip-loop-delay="2000"
      :pause-on-hover="hoverPause"
    >
      <!-- 两列布局 -->
      <div class="chart-grid">
        <!-- 收入对比图表 -->
        <ChartItem
          type="line"
          title="月度收入趋势"
          :xAxisData="monthLabels"
          :series="incomeSeriesData"
          height="300px"
          :options="commonOptions"
        />

        <!-- 消纳率对比图表 -->
        <ChartItem
          type="bar"
          title="消纳率对比"
          :xAxisData="monthLabels"
          :series="rateSeriesData"
          height="300px"
          :options="commonOptions"
        />
      </div>

      <!-- 电价对比图表(整行) -->
      <ChartItem
        type="line"
        title="电价对比"
        :xAxisData="monthLabels"
        :series="priceSeriesData"
        height="300px"
        :options="priceChartOptions"
      />
    </ChartGroup>
  </div>
</template>

<script setup>
import { ref, onMounted } from 'vue';
import ChartGroup from '@/components/ChartGroup.vue';
import ChartItem from '@/components/ChartItem.vue';

// 控制逻辑和数据定义
// ...
</script>

高级功能

自动轮播 Tooltip

ChartGroup 组件提供了强大的自动轮播 Tooltip 功能,可以自动展示图表中的数据点,提高数据可视化的交互性。

工作原理:

  1. 组件内部维护一个定时器,按照设定的间隔依次显示每个图表的 Tooltip
  2. 支持鼠标悬停暂停功能,当用户与图表交互时暂停自动轮播
  3. 支持自定义轮播间隔、展示时长和循环延迟
  4. 自动处理数据缩放,确保 Tooltip 始终显示在可见区域内

使用示例:

<ChartGroup
  :auto-tooltip="true"
  :tooltip-interval="3000"
  :tooltip-duration="2000"
  :tooltip-loop-delay="2000"
  :pause-on-hover="true"
>
  <!-- 图表内容 -->
</ChartGroup>

图表联动

ChartGroup 组件通过 ECharts 的 connect 功能实现图表联动,当用户与一个图表交互时,其他图表会同步响应。

工作原理:

  1. 组件为所有子图表分配相同的 groupId
  2. 使用 echarts.connect(groupId) 建立图表间的连接
  3. 当用户缩放、平移或高亮某个图表时,其他图表会同步更新

使用示例:

<ChartGroup>
  <ChartItem
    type="line"
    title="图表1"
    :xAxisData="xAxisData"
    :series="series1"
    :options="{
      dataZoom: [
        {
          type: 'slider',
          show: true,
          xAxisIndex: [0],
          start: 0,
          end: 100,
        }
      ]
    }"
  />
  <ChartItem
    type="bar"
    title="图表2"
    :xAxisData="xAxisData"
    :series="series2"
    :options="{
      dataZoom: [
        {
          type: 'slider',
          show: true,
          xAxisIndex: [0],
          start: 0,
          end: 100,
        }
      ]
    }"
  />
</ChartGroup>

数据缩放

组件支持数据缩放功能,允许用户查看大量数据中的特定部分。

工作原理:

  1. 通过 dataZoom 配置启用数据缩放功能
  2. 支持滑块和内部缩放两种方式
  3. 当用户缩放一个图表时,其他图表会同步缩放范围

使用示例:

const commonOptions = {
  dataZoom: [
    {
      type: 'slider',
      show: true,
      xAxisIndex: [0],
      start: 0,
      end: 100,
      id: 'dataZoomX',
      realtime: true,
    },
    {
      type: 'inside',
      start: 0,
      end: 100,
      id: 'dataZoomInside',
      xAxisIndex: [0],
    },
  ],
};

// 在 ChartItem 中使用
<ChartItem
  type="line"
  title="图表"
  :xAxisData="xAxisData"
  :series="series"
  :options="commonOptions"
/>

最佳实践

  1. 合理设置轮播间隔:根据数据量和用户阅读速度调整 tooltipInterval,建议在 2000-5000 毫秒之间
  2. 启用悬停暂停:设置 pauseOnHovertrue,提高用户体验
  3. 使用响应式布局:利用 CSS Grid 或 Flex 布局创建响应式图表布局
  4. 统一数据格式:确保所有图表使用相同的数据格式和结构,便于维护
  5. 自定义 Tooltip 格式:根据数据特点自定义 Tooltip 的显示格式,提高可读性
// 自定义 Tooltip 格式示例
const priceChartOptions = {
  tooltip: {
    formatter: function (params) {
      let result = params[0].name + '<br/>';
      params.forEach((item) => {
        result += `${item.marker}${item.seriesName}: ${item.value} 元/kWh<br/>`;
      });
      return result;
    },
  },
};

通过以上组件和功能,您可以创建功能丰富、交互性强的数据可视化界面,满足各种业务场景的需求。

解锁 Ant Design MCP 组件查询新姿势:大模型组件查询新范式

antd-components-mcp.png

引言

当下 AI 盛行,层出不穷的大语言模型、IDE、Extension,其中热门的有 Chatgpt o3、Claude 3.7 Sonnet、Gemini 2.5 pro、Grok 3、Deepseek v3-0424、Cursor、Trae 国际版、Github Copilot、Cline,个人主要使用的是以下白嫖方案:

  • Trae 国际版
  • VS Code + Cline
  • VS Code + Github Copilot - 学生版 结合上白嫖的 Gemini exp 版本、OpenRouter free modal

通用大模型非常强大,但是在千人千面的项目场景中,通用的大模型缺少了点相关性,想要增加相关性可以通过:添加 rules、设定 system prompt/不断地添加 prompt、添加 rag、开发定向 MCP 服务、微调模型、甚至是训练公司项目特有模型,方案挺多的实际要看情况选择合适的方案,本文目的是通过 [MCP + system prompt] 方案实现让大模型可以理解我们的特定组件信息,让大模型可以生成更相关的代码

MCP 是什么

MCP 全称 Model Context Protocol 是一种用于描述模型上下文的协议,它允许模型在生成文本时获取更多的上下文信息。MCP 协议定义了一种标准的方式来描述模型的上下文,包括模型的名称、版本、输入和输出格式等信息。

简单理解就是:

每一个 MCP Server 都是大语言模型客户端的插件,MCP 可以提供 Tools、Prompt、Resource 等功能供客户端使用,客户端启动时读取所有的 MCP 的 Tools、Prompt、Resource 描述当做大语言模型的系统提示词,客户端根据模型返回的内容判断调用对应的 MCP 获取函数返回内容,客户端将用户输入 + 函数返回内容发送给大语言模型,大语言模型根据用户的问题生成更相关的回复

  • 客户端:Cursor、Claude Desktop、Github Copilot 等
    • 目前测试下来仅 Claude Desktop 支持 Prompt、Resource
  • Tools:提供核心的处理函数,大模型根据 tool 名称 + 描述 + 约定的入参来准确的调用对应的函数,函数的返回值最终会结合用户提问发送给大模型
  • Prompt:预设的提示词,支持配置表单输入,使用时需要手动选定,通常是用来帮助提升 MCP Tools 的使用能力
    • 例如本文通过预设的 system-description 提示词来圈定能力、优化工具调用频次、优化上下文
  • Resource:预设的内容,如 Antd Button 组件文档,使用时需要手动选定

再简化:MCP 会组成系统提示词,大语言模型根据系统提示词来精准执行函数生成更相关的回复

MCP 重点:提供强大的描述词 + 提供强大的处理函数,让大模型可以理解我们的特定组件信息然后生成更相关的代码

项目背景

在一个月前我了解了 MCP 协议相关知识后,开始思考作为前端有没有什么可以开发的功能,毕竟只有开始动手开发才算真正的入门,才会有后续更多的可能,于是开始去了解 MCP 资源站(mcp.so 最开始关注的时候 mcp.so 上仅有几百个服务,截止 2025-04-16 已经有 8888 了、smithery.aiglama.ai)、知乎、Github,最终参照着一篇知乎文档结合 Trae 实现了一个桌面图片管理的 MCP desktop-image-manager-mcp

实现了一个包含基础 Tool 功能的 MCP 后开始想去体验更完整以及更贴合工作的能力,如:

  1. MCP 的 Prompt、Resource 是具体用法是怎么样的 - 官方文档看不出用法
  2. 能不能基于现有内容实现更加前端相关的 MCP

最终打算实现:一个 xx 组件信息查询的 MCP 服务,可以让大模型回答问题,上传 UI 稿或者需求后生成更相关的代码

方案思考

要点:过程简单一点,先实现再优化

核心思路

实现思路

实现思路

  1. 准备组件文档数据供 MCP Tools 使用
    1. 有哪些组件
    2. 组件的属性、例子、注意事项是什么
  2. MCP Tools 告诉大模型何时使用
  3. 大模型结合用户问题 + MCP Tools 返回提高回答的相关性

这样对组件库就有了一定的要求

组件库选型要求

  1. 组件库文档清晰、规范,每个组件都有完整的说明
  2. 最好是有中英文文档:英文文档通常效果更好
  3. 组件有大量高质量的例子说明
  4. 加分项
    1. 组件发布时间较早、社区活跃:大模型训练进去的数据效果更好
    2. 有很好的组件更新说明:可以告知升级到某个版本后可以使用相关功能/修复相关 BUG

基于以上要求选择合适的组件库进行 MCP 开发将会事半功倍,所以选了常用且广为人知的 Ant Design 组件库

技术方案

Github Repo: zhixiaoqiang/antd-components-mcp

架构设计

整体架构

整体架构

数据流转

数据流转

MCP Ant Design 组件服务采用模块化设计,主要包含以下核心模块:

组件文档数据提取

提取工具架构

开发提取数据的命令行工具从 Ant Design 仓库提取文档并保存到当前 npm 包目录,预提取后可以做到开箱即用,同时将提取脚本暴露出去,方便用户按照项目实际的版本进行文档提取

主要提取如下内容:

  1. 读取 components 目录生成可用组件列表
    1. 输出 JSON 格式
    2. 通过 vfile-matter 解析元信息:tag、description
  2. 读取 components/[component]/index.zh-CN.md 生成组件文档
    1. 输出 markdown 格式
  3. 提取 components/[component]/index.zh-CN.md 中的 API 模块生成组件API/属性文档
    1. 输出 markdown 格式
  4. 读取 components/[component]/demo/*.{md|tsx} 整合到一个组件示例代码文档
    1. 输出 markdown 格式
    2. 为什么要生成示例代码集而不是生成单独的示例代码?
      1. 使用者不确定有哪些例子大模型不容易精准识别特定例子
      2. 示例代码集可以提高大模型的容错率
  5. 执行 pnpm lint:changelog 生成组件级别的 changelog,提取位置:ant-design/.dumi/preset/components-changelog-cn.json
    1. JSON 格式

提取至少需要支持:Antd v4.x、Antd v5.x

提取优化

节省 token 消耗

通过在线的 opanai tokenizer 实时查看 token 数值

  1. 提取组件文档时过滤掉无用的内容,如:
    • 过滤无效组件
    • 移除 meta 信息
    • 移除多余的空行
    • 移除主题样式
    • 同时存在中英文文档时的英文文档
    • 移除其他的无用内容
  2. API 文档包含在文档中,过滤掉无用内容后差异不大,移除单独的 API 文档
  3. JSON 数据通过 JSON.stringify 压缩

Tools 设计

  1. Tool1:获取可用组件列表,包含:
    1. 组件名称
    2. 组件描述
    3. 组件可用版本
    4. 何时使用
  2. Tool2:获取组件详细文档,包含:
    1. 组件名称
    2. 组件描述
    3. 何时使用
    4. API 文档
    5. FAQ 3. Tool3:获取组件 API 文档,包含 1. API 文档
  3. Tool4:获取组件示例代码,包含
    1. 全量示例
  4. Tool5:获取组件的更新记录
    1. 组件的更新列表

MCP Tools 优化

优化 IO 执行

使用缓存,减少 IO 操作

缓存

节省 token 消耗

  1. Tools 描述缩减内容
  2. Tools 输出内容缩减内容,过滤不需要的数据
  3. 通过提供的 MCP Prompt,有效减少重复的工具调用

MCP Prompt 设计

  • system-description: 专业的 Ant Design 组件库专家助手,可有效减少重复性的工具调用
你是一个专业的 Ant Design 组件库应用专家助手,具有以下能力:
1. 可以查询所有可用组件列表
2. 能获取组件的详细文档、属性说明和API定义
3. 能提供组件的代码示例
4. 能查询组件的更新历史

使用规则:
- 严格遵循以下工具使用优先级:
  1. 首先检查当前对话上下文是否已包含所需信息
  2. 只有当上下文确实缺少必要信息时才调用工具
  3. 对于完全相同的组件查询参数,禁止重复调用工具
- 对专业术语保持准确,不自行编造组件属性
- 代码示例要完整可运行,并注明所需版本

PS:考虑到部分客户端不支持使用 prompt,可自行复制如上 prompt

实现特性

  • 🚀 已预处理数据,开箱即用(预处理版本为:Ant Design V5.24.7 2025/4/16)
    • 🔨 可以自行提取最新的/其他版本的组件文档
  • 🔗 列出所有可用的 Ant Design 组件
    • 📃 包含组件名称、描述、可用版本、何时使用当前组件信息
  • 📃 查看特定组件文档(已过滤无意义内容,对上下文友好)
  • 📃 查看特定组件属性和 API 定义
  • 📃 查看特定组件组件的代码示例
  • 📖 查看特定组件组件的更新日志
  • 💪 做了大量的缓存,有效缓解 IO 压力
  • ⚙️ 提供了预置的 prompt,有效减少重复的工具调用(对上下文优化)
    • 😺 测试下来 Claude 客户端可以使用
    • 😩 github copilot/Cline 插件暂时无法使用

最佳实践

在 Claude 桌面版中使用此 MCP 服务器

第一步:确定文档数据

当前包内维护的为 5.24.x 版本的文档数据,如果你使用的是 V5 版本的话直接前往第二步,如果你想使用其他版本的组件文档,需要自行提取组件文档

什么时候需要自行提取组件文档?

  1. 你想使用最新的组件文档
  2. 你想使用其他版本的组件文档

提取组件文档

# 克隆 Ant Design 仓库
git clone https://github.com/ant-design/ant-design.git --depth 1 --branch master --single-branch --filter=blob:none

# 在当前目录执行提取文档命令
npx @jzone-mcp/antd-components-mcp extract [ant design repo path]  #默认提取路径为 ./ant-design

第二步:配置 MCP

编辑 claude_desktop_config.json 配置文件:

{
  "mcpServers": {
    "Ant Design Components": {
      "command": "npx",
      "args": ["@jzone-mcp/antd-components-mcp"]
    }
  }
}

配置文件位置:

  • macOS/Linux: ~/Library/Application Support/Claude/claude_desktop_config.json
  • Windows: $env:AppData\Claude\claude_desktop_config.json

以下是配置成功的示例:

ensure-tools-setup

第三步:添加 MCP 内置提示词

通过 Claude Desktop 添加 Prompt

Claude Desktop Setup Prompt

不支持 MCP Prompt 的客户端直接复制如下提示词:

你是一个专业的 Ant Design 组件库应用专家助手,具有以下能力:
1. 可以查询所有可用组件列表
2. 能获取组件的详细文档、属性说明和API定义
3. 能提供组件的代码示例
4. 能查询组件的更新历史

使用规则:
- 严格遵循以下工具使用优先级:
  1. 首先检查当前对话上下文是否已包含所需信息
  2. 只有当上下文确实缺少必要信息时才调用工具
  3. 对于完全相同的组件查询参数,禁止重复调用工具
- 对专业术语保持准确,不自行编造组件属性
- 代码示例要完整可运行,并注明所需版本

第四步:进行对话

Ant Design 有哪些可用组件?

上传图片示例后,使用 Ant Design 实现如图功能。

显示 Button 组件的文档。

Button 组件接受哪些属性?

显示 Button 组件的代码示例。

查看 Button 组件的基础用法。

查看 Button 组件的更新记录

未来规划

  • 实现监听 Ant Design 组件库的更新,自动进行数据提取发版
  • 考虑为工具调用添加上下文感知,如前文已获取,则返回:"请使用前文获取的内容"
    • 通过 sessionId 处理
    • 客户端通常可以实现重新编辑对话,所以需要考虑当前情况
  • 添加详细的 mcp tools 例子文档
  • 考虑将提取的数据考虑放到 CDN 上,使用时实时获取
    • 实际上 npx 执行时会检测新版并安装新版本使用,目前可以保证数据实时性
  • 考虑支持通过传参调整 tool 的注册来改善上下文,或者通过自带的 disable/enable 开关来控制工具的注册
    • 目前部分 client 已支持手动开关单一工具:cline、github copilot等
  • 考虑兼容 Ant Design 4.x 版本或者其他 UI 库
    • 如 Ant Design X 等系列组件库

总结

由 DeepSeek-V3-0324 生成

通过开发 Ant Design 组件 MCP 服务,我们实现了以下核心价值:

  1. 精准组件知识获取:能够快速查询 Ant Design 组件的详细文档、API 定义和示例代码,解决了开发过程中频繁查阅文档的低效问题。

  2. AI 辅助开发提效:通过与 Claude 等 AI 客户端的集成,实现了:

    • 自然语言查询组件信息
    • 根据 UI 稿或需求生成相关代码
    • 获取组件更新历史等高级功能
  3. 技术方案创新

    • 开发了自动化文档提取工具,支持多版本 Ant Design
    • 设计了高效的缓存机制减少 IO 操作
    • 优化了 token 使用,降低大模型调用成本
  4. 可扩展架构:模块化设计使得该方案可以轻松适配其他 UI 组件库,如 Ant Design 4.x 或其他流行框架。

实践价值

  • 对开发者:节省 50%以上的组件查阅时间,提高代码质量和一致性,可参照源码实现自己的组件库 MCP 服务
  • 对团队:建立标准化组件使用规范,降低新人学习成本
  • 对企业:可快速构建内部组件库的智能辅助系统

未来展望

随着 MCP 生态的成熟,我们可以进一步:

  • 实现组件变更的自动监控和更新
  • 增加更多上下文感知能力
  • 扩展支持更多 UI 框架和版本

这个项目不仅验证了 MCP 协议在前端领域的实用价值,也为构建领域特定的 AI 辅助工具提供了可复用的技术方案。开发者可以基于此思路,快速构建自己业务领域的智能辅助系统。

2025前端社招最新面试题汇总- 场景题篇

1. pdf预览

// 1.href
<a href='a.pdf' target="_blank">

// 2.window
window.open('a.pdf','_blank')

3. 使用 PDF.js

PDF.js是一个由 Mozilla 开发的开源库,它使用 HTML5 Canvas 来渲染 PDF 文件。PDF.js 提供了广泛的 API 来实现 PDF 的加载、渲染、缩放、打印等功能。

<!-- 引入pdf.js和pdf.worker.js -->
<script src="/path/to/pdf.js"></script>
<script src="/path/to/pdf.worker.js"></script>

<!-- PDF容器 -->
<div id="pdf-container"></div>

<script>
  // 初始化PDF.js
  pdfjsLib.getDocument("/path/to/your/document.pdf").promise.then(function (pdfDoc) {
    // 获取第一页
    pdfDoc.getPage(1).then(function (page) {
      // 设置视口和比例
      var scale = 1.5;
      var viewport = page.getViewport({ scale: scale });

      // 准备用于渲染的Canvas
      var canvas = document.createElement("canvas");
      var ctx = canvas.getContext("2d");
      canvas.height = viewport.height;
      canvas.width = viewport.width;

      // 将Canvas添加到DOM中
      document.getElementById("pdf-container").appendChild(canvas);

      // 通过Canvas渲染PDF页面
      var renderContext = {
        canvasContext: ctx,
        viewport: viewport,
      };
      page.render(renderContext);
    });
  });
</script>

使用第三方服务

也可以使用第三方服务如 Google Docs Viewer 来预览 PDF。这种方法的优点是容易实现,但依赖于外部服务。

<iframe
  src="http://docs.google.com/gview?url=http://path.to/your/document.pdf&embedded=true"
  style="width:600px; height:500px;"
  frameborder="0"
></iframe>

其中,将http://path.to/your/document.pdf替换为你的 PDF 文件的真实 URL。

2. 日志监控问题:可有办法将请求的调用源码地址包括代码行数也上报上去?

2.1.1. 源码映射(Source Maps)

SourceMap 主要用于调试目的,让开发者能够在压缩或转译后的代码中追踪到原始代码

webpack中 配置 devtool: 'source-map'后,

在编译过程中,会生成一个 .map 文件,一般用于代码调试和错误监控。

  • 包含了源代码、编译后的代码、以及它们之间的映射关系。
  • 编译后的文件通常会在文件末尾添加一个注释,指向 SourceMap文件的位置。
    • // # sourceMappingURL=example.js.map
  • 当在浏览器开发者工具调试时,浏览器会读取这行注释并加载对应的 SourceMap 文件

报错时,点击跳转。即使运行的是编译后的代码,也能够追溯到原始源代码的具体位置,而不是处理经过转换或压缩后的代码,从而提高了调试效率。

2.1.2. 自定义错误日志逻辑

使用try .. catch 自定义报错逻辑,便于错误的追踪。

3. 用户线上问题的解决

首先看是否是突然性大量用户受到影响,如果是可能是上线新功能影响,则应该立即回退,降低影响范围

然后再处理问题。复现问题-判断是前端还是后端问题。

  • 浏览器缓存
  • 插件影响
  • 网络问题
  • 浏览器版本问题
  • 问题解决后需要进行复盘。

4. 大文件上传

  • 前端上传大文件时使用 file.slice 将文件切片,并发上传多个切片(有标号, Blob 对象),最后发送一个合并的请求通知
  • 使用formData 上传文件
    • 将分块后的 Blob 对象封装到FormData中,以便通过 HTTP 请求发送。FormData对象提供了一种简单的方式来构造一个包含表单数据的对象,并且可以直接作为fetchaxios请求的body参数。
  • 服务端合并切片
    • 切片上传可以将上传成功的切片通过localstorage保存,再 继续上传失败的内容
  • 服务端接收切片并存储,收到合并请求后使用流将切片合并到最终文件
  • 原生 XMLHttpRequest 的 upload.onprogress 对切片上传进度的监听
  • 使用 Vue 计算属性根据每个切片的进度算出整个文件的上传进度
<template>
   <div>
    <input type="file" @change="handleFileChange" />
    <el-button @click="handleUpload">upload</el-button>
  </div>
</template>

<script>
// 切片大小
// the chunk size
const SIZE = 10 * 1024 * 1024; 
export default {
  data: () => ({
    container: {
      file: null
    },
    data: []
  }),
  methods: {
     handleFileChange(e) {
      const [file] = e.target.files;
      if (!file) return;
      Object.assign(this.$data, this.$options.data());
      this.container.file = file;
    },
    // 生成文件切片
+    createFileChunk(file, size = SIZE) {
+     const fileChunkList = [];
+      let cur = 0;
+      while (cur < file.size) {
+        fileChunkList.push({ file: file.slice(cur, cur + size) });
+        cur += size;
+      }
+      return fileChunkList;
+    },
+   // 上传切片
+    async uploadChunks() {
+      const requestList = this.data
+        .map(({ chunk,hash }) => {
+          const formData = new FormData();
+          formData.append("chunk", chunk);
+          formData.append("hash", hash);
+          formData.append("filename", this.container.file.name);
+          return { formData };
+        })
+        .map(({ formData }) =>
+          this.request({
+            url: "http://localhost:3000",
+            data: formData
+          })
+        );
+      // 并发请求
+      await Promise.all(requestList); 
+    },
+    async handleUpload() {
+      if (!this.container.file) return;
+      const fileChunkList = this.createFileChunk(this.container.file);
+      this.data = fileChunkList.map(({ file },index) => ({
+        chunk: file,
+        // 文件名 + 数组下标
+        hash: this.container.file.name + "-" + index
+      }));
+      await this.uploadChunks();
+    }
  // 合并切片
+     await this.mergeRequest();
    },
+    async mergeRequest() {
+      await this.request({
+        url: "http://localhost:3000/merge",
+        headers: {
+          "content-type": "application/json"
+        },
+        data: JSON.stringify({
+          filename: this.container.file.name
+        })
+      });
+    },    
  }
};
</script>

5. 文本点开收起展开

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

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <div id="textContainer" class="text-overflow">
        这是一段可能很长的文本,我们希望在一开始时只显示部分,点击“展开”按钮后显示全部内容,再次点击则“收起”文本。
    </div>
    <button id="toggleButton">展开</button>
    <script>
        const button = document.getElementById('toggleButton');
        button.addEventListener('click', () => {
            const container = document.getElementById('textContainer');
            if (button.textContent === '展开') {
                button.textContent = '收起';
                container.style.whiteSpace = 'normal'
            } else {
                button.textContent = '展开';
                container.style.whiteSpace = 'nowrap'
            }

        })
    </script>
    <style>
        .text-overflow {
            width: 300px;
            overflow: hidden;
            text-overflow: ellipsis;
            white-space: nowrap
        }
    </style>
</body>

</html>

6. 富文本划线获取

document.addEventListener('mouseup',(e) => {
  const selection = window.getSelection()
  if(selection) {
    const res = selection.toString();
    console.log(res)
  }
})

7. 鼠标拖拽

实现鼠标拖拽功能通常涉及到监听和处理鼠标事件,比如:mousedownmousemovemouseup事件。

<button id="toggleButton">展开</button>
    <script>
        const button = document.getElementById("toggleButton")
        button.style.cursor = 'pointer'
        button.style.position = 'absolute'

        let dist = {
            x: 0,
            y: 0
        }

        let isdraggable = false;
        button.addEventListener('mousedown', (e) => {
            isdraggable = true;
            dist.x = e.pageX - button.offsetLeft;
            dist.y = e.pageY - button.offsetTop;

        })
        button.addEventListener('mousemove', (e) => {
            if (isdraggable) {
                button.style.left = e.pageX - dist.x + 'px'
                button.style.top = e.pageY - dist.y + 'px'
            }

        })

        button.addEventListener('mouseup', (e) => {
            if (isdraggable) {
                isdraggable = false;
                dist.x = 0
                dist.y = 0
            }
        })
    </script>

8. 要统计全站每一个静态资源(如图片、JS 脚本、CSS 样式表等)的加载耗时

  • 使用 PerformanceObserver 创建一个 PerformanceObserver 实例来监听资源加载事件,能够实时收集性能数据,而且对性能影响较小。
  • 过滤静态资源类型: 通过检查 initiatorType 属性,筛选出静态资源(例如 imgscriptcss 等)的加载事件。
  • 计算和展示耗时: 对每个静态资源的加载耗时进行计算并展示。资源的耗时可以通过 duration 属性直接获取。
// 创建性能观察者实例来监听资源加载事件
const observer = new PerformanceObserver((list) => {
  const entries = list.getEntries();
  for (const entry of entries) {
    // 过滤静态资源类型
    if (["img", "script", "css", "link"].includes(entry.initiatorType)) {
      console.log(`资源 ${entry.name} 类型 ${entry.initiatorType} 耗时:${entry.duration.toFixed(2)} 毫秒`);
    }
  }
});

// 开始观察 Resource Timing 类型的性能条目
observer.observe({ entryTypes: ["resource"] });

9. 如何防止前端接口重复发送

1、提交按钮点击后增加loading, 防止重复点击

2、节流或防抖

3、使用缓存

对于一些数据不经常变化的请求,例如用户信息、配置数据等,可以将请求的结果缓存起来。下一次请求相同的资源时,先从缓存中读取数据,如果缓存有效,则无需再发起新的网络请求。

10. 一次性渲染十万条数据

10.1. 全部渲染-卡死

这种方法虽然实现起来简单直接,但由于它在一个循环中创建并添加了所有列表项至DOM树,因此在执行过程中,浏览器需要等待JavaScript完全执行完毕才能开始渲染页面。当数据量非常大(例如本例中的100,000个列表项)时,这种大量的DOM操作会导致浏览器的渲染队列积压大量工作,从而引发页面的回流与重绘,浏览器无法进行任何渲染操作,导致了所谓的“阻塞”渲染。

10.2. setTimeout分批渲染 或 requestAnimationFrame

为了避免一次性操作引起浏览器卡顿,我们可以使用setTimeout将创建和添加操作分散到多个时间点,每次只渲染一部分数据。

let ul=document.getElementById('container');
const total=100000
let once= 20
let page=total/once
let index=0

function loop(curTotal,curIndex){
  let pageCount=Math.min(once,curTotal)
  setTimeout(()=>{
    for(let i=0;i<pageCount;i++){
      let li=document.createElement('li');
      li.innerText=curIndex+i+':'(Math.random()*total)
      ul.appendChild(li)
    }
    loop(curTotal-pageCount,curIndex+pageCount)
  })
}
loop(total,index)

这里就是把浏览器渲染时的压力分摊给了js引擎js引擎是单线程工作的,先执行同步,异步,然后浏览器渲染,再宏任务,这里就很好的利用了这一点,把渲染的任务分批执行,减轻了浏览器一次要渲染大量数据造成的渲染“阻塞”,也很好的解决了数据过多时可能造成页面卡顿或白屏的问题,

使用 requestAnimationFrame 替代 setTimeout,将数据拆分为每帧处理 20-50 条,避免主线程阻塞。相比 setTimeout,帧率更稳定且与浏览器渲染周期同步‌

10.3. 分页实现渲染

10.4. 虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

<template>
  <div ref="listWrapper" class="list-wrapper" @scroll="handleScroll">
    <div class="visible-items" :style="{ transform: `translateY(${startIndex * itemHeight}px)` }">
      <div v-for="item in visibleItems" :key="item.id" class="list-item">
        {{ item.text }}
      </div>
    </div>
  </div>
</template>

<script>
export default {
  data() {
    return {
      items: [/* 假设这里是你的大数据列表 */],
      itemHeight: 50, // 假设每个列表项的高度是固定的
      startIndex: 0, // 当前可视区域的起始索引
      visibleCount: 10 // 可视区域内同时显示的列表项数量
    };
  },
  computed: {
    endIndex() {
      return Math.min(this.startIndex + this.visibleCount, this.items.length);
    },
    visibleItems() {
      return this.items.slice(this.startIndex, this.endIndex);
    }
  },
  methods: {
    handleScroll() {
      const scrollTop = this.$refs.listWrapper.scrollTop;
      this.startIndex = Math.floor(scrollTop / this.itemHeight);
    }
  }
};
</script>

<style scoped>
.list-wrapper {
  height: 300px; /* 设定滚动容器的高度 */
  overflow-y: auto; /* 允许垂直滚动 */
  position: relative;
}
.list-item {
  height: 50px; /* 与data中的itemHeight保持一致 */
  /* 其他样式 */
}
</style>

11. 如何判断用户设备

  • 用户代理字符串包含了浏览器类型、版本、操作系统等信息,可以通过分析这些信息来大致判断用户的设备类型。navigator.userAgent 属性用于获取用户代理字符串。

  • 使用window.innerWidth 检测视口宽度

12. IntersectionObserver + scrollIntoView 实现电梯导航

juejin.cn/post/739998…

电梯导航也被称为锚点导航

  • 当点击锚点元素时,页面内相应标记的元素滚动到视口。 scrollIntoView
  • 页面内元素滚动时相应锚点也会高亮。IntersectionObserver
// 点击锚点跳转
let rightBox = document.querySelector('.rightBox')
  rightBox.addEventListener('click', function (e) {
    let target = e.target || e.srcElement;
    if (target && !target.classList.contains('rightBox')) {
      document.querySelector('.' + target.className.replace('Li', '')).scrollIntoView({
        behavior: 'smooth',
        block: 'center'
      })
    }
  }, false)
// 页面滚动时,对应锚点样式改变
// 也可以使用计算el.offsetTop < window.innerHeight + document.documentElement.scrollTop判断
// 是否进入视口
let observer = new IntersectionObserver(function (entries) {
  entries.forEach(entry => {
    let target = document.querySelector('.' + entry.target.className + 'Li')
    if (entry.isIntersecting) { // 出现在检测区域内
      document.querySelectorAll('li').forEach(el => {
        if(el.classList.contains('active')){
          el.classList.remove('active')
        }
      })
      if (!target.classList.contains('active')) {
        target.classList.add('active')
      }
    }
  })
}, {
  threshold: 1
})

document.querySelectorAll('div').forEach(el => {
  observer.observe(el)
})

13. 退出浏览器之间, 发送积压的埋点数据请求

  • fecth的 keepalive属性
  • navigator.sendBeacon()

navigator.sendBeacon() 方法允许你在浏览器会话结束时异步地向服务器发送小量数据。这个方法的设计初衷就是为了解决上述问题。sendBeacon() 在大多数现代浏览器中得到支持,并且其异步特性意味着它不会阻塞页面卸载或影响用户体验。

window.addEventListener("beforeunload", function (event) {
  var data = {
    /* 收集的埋点数据 */
  };
  var beaconUrl = "https://yourserver.com/path"; // 你的服务器接收端点

  navigator.sendBeacon(beaconUrl, JSON.stringify(data));
});

fetch() API 的 keepalive 选项是另一个选择。这个选项允许你发送一个保持存活状态的请求,即使用户已经离开页面。但是,需要注意的是,使用 keepalive 选项发送的请求有大小限制(大约为 64KB)。

window.addEventListener("beforeunload", function (event) {
  var data = {
    /* 收集的埋点数据 */
  };
  var beaconUrl = "https://yourserver.com/path"; // 你的服务器接收端点

  fetch(beaconUrl, {
    method: "POST",
    body: JSON.stringify(data),
    headers: {
      "Content-Type": "application/json",
    },
    keepalive: true, // 保持请求存活
  });
});

14. 代码打印

const { a = 1, b = 2, c = 3 } = { a: '', b: undefined, c: null };
// 只有设置为undefined的时候或者没有这个属性的时候才使用默认值
console.log(a, b, c); //    2, null 


// 考察运符号优先级和 加法
// const result = undefined || (1 + undefined) || 2;
// undefined转换成数字是NaN, 1+NaN = NaN
//  const result = undefined || NaN || 2;
// 最终输出2
const result = undefined || 1 + undefined || 2;

console.log(result); 

15. 多核处理任务

多核环境下的性能优化需求和JavaScript特性,可采用Web Workers结合时间分片技术实现非阻塞定时任务处理。以下是基于JavaScript类的实现方案

 // worker.js
self.onMessage = (data) => {
    const start = Date.now()
    while (Date.now() - start < 100) {
        continue
    }
    self.postMessage(data)
}
// main

class MultTask {
    constructor(concurrency = 4) {
        this.workersPool = [];
        this.taskQueue = [];
        for (let i = 0; i < concurrency; i++) {
            const worker = new Worker('worker.js');
            worker.onmessage = (data) => this.#handleResult(data, worker)
            worker.task = null;
            this.workersPool.push(worker)
        }
    }

    addTask(input) {
        return new Promise((resolve, reject) => {
            const task = {
                id: Date.now(),
                input,
                resolve,
            }
            this.taskQueue.push(task)
            this.#run()
        })
    }

    #run() {
        if (!this.taskQueue.length) return;
        const availWorker = this.workersPool.find(item => !item.busy)
        if (!availWorker) return;
        availWorker.busy = true;
        const task = this.taskQueue.shift();
        availWorker.task = task;

        availWorker.postMessage({
            id: task.id,
            input: task.input
        })
    }

    #handleResult(data, worker) {
        worker.busy = false;
        worker.task.resolve(data);
        worker.task = null;
        this.#run()
    }
}

// 使用实例
const processor = new MultTask(3)
setInterval(() => {
    processor.addTask(Math.random()).then(res => console.log(res))
}, 40)

16. 浏览器环境下幂级计算的优化方案

快速幂运算 + webworker 结合

快速幂算法
通过二进制分解指数,将计算复杂度从 O(n) 优化至 O(log n),减少乘法次数。例如计算 a15 时,分解为 a8×a4×a2×a1,仅需 4 次乘法而非 14 次‌


// worker.js
// // 分解为子问题计算,递归
function fast(a, n) {
  if (n === 0) return 1;

  if(n < 0) return 1 / fast(a, -n);
  
  const half = fast(a, Math.floor(n / 2));
  return n % 2 === 0 ? half * half : half * half * a
}

self.onmessage = (a,n) => {
  const res = fastPower(a,n)
  self.postmessage(res)
}

// main
const worker = new Worker(./worker.js)
worker.postmessage(2,15)
worker.onmessage = (res) => {
  console.log(res)
}

魔改chromium源码——自定义浏览器启动参数

在进行以下操作之前,请确保已完成之前文章中提到的 源码拉取及编译 部分。

如果已顺利完成相关配置,即可继续执行后续操作。


为什么需要自定义传参?

在讨论这个问题之前,我们需要引入一个重要的背景概念:环境持久化 和运行环境一致性 。这是许多现代浏览器应用场景中不可忽视的关键点。

众所周知,当浏览器启动时,其内部参数(如指纹、用户代理、硬件信息等)会被初始化并固定下来。例如,假设你登录了某个网站(比如A网站),第一次启动浏览器时,系统计算出的设备指纹是1111。但当你关闭浏览器后再次打开时,由于浏览器内部可能默认启用了指纹随机化机制 ,指纹可能会变成2222。对于A网站的风控系统来说,这种指纹的变化会被视为异常行为,进而将你的运行环境标记为可疑,甚至可能导致账户被限制或封禁。 因此,在实际应用中,为了实现更灵活且可控的浏览器行为,我们需要解决以下几个核心问题:

  1. 明确随机化的控制点 :我们需要知道哪些参数是可以被随机化的,以及如何修改它们。
  2. 实现随机值的持久化:随机生成的值需要能够在多次启动之间保持一致,避免因频繁变化而触发风控。
  3. 支持外部干预:通过外部手段动态调整内部参数,以满足不同场景的需求。

在这种情况下,自定义传参 就显得尤为重要。通过在浏览器启动时传递不同的参数,我们可以为每一个浏览器实例设置独立的配置,从而实现多样化的运行效果。例如:

  • 数据隔离 :浏览器自带的--user-data-dir参数可以指定不同的用户数据目录,确保每个实例的数据完全隔离。
  • 指纹定制 :通过传参,我们可以为每个浏览器实例分配固定的指纹值,避免因随机化导致的风控问题。
  • 灵活扩展 :自定义传参不仅限于指纹,还可以用于调整其他关键参数(如分辨率、语言、时区等),从而更好地模拟真实用户环境。

综上所述,自定义传参是实现环境持久化和运行环境一致性的基础工具之一。它不仅能够帮助我们规避风控系统的误判,还能为复杂的应用场景提供更高的灵活性和可控性。这正是我们在设计和使用浏览器自动化方案时需要重点关注的核心能力

以下为实现步骤以及验证方式:

验证方式我们会用到 新增自定义变量到windows属性 这篇文章中的方法,将传参接收的值挂载到 window 全局变量。然后在devtool中输出,验证我们传值是否符合预期。

步骤 1:定义新的命令行开关

修改位置:

src\chrome\common\chrome_switches.h

在 namespace switches 中新增变量

extern const char kMyCustomFlag[]; // 新增

在这里插入图片描述

修改位置:

src\chrome\common\chrome_switches.cc

在 namespace switches 中新增变量,my-custom-flag 就是我们要传的链接参数

const char kMyCustomFlag[] = "my-custom-flag"; // 新增

在这里插入图片描述

步骤 2:创建 JavaScript 绑定,用于测试验证

在 src\content\renderer\ 中新增文件 my_custom_flag.h

#ifndef CONTENT_RENDERER_MY_CODE_BINDING_H_
#define CONTENT_RENDERER_MY_CODE_BINDING_H_

#include "v8/include/v8.h"

namespace content {

class myCustomFlagBinding {
 public:
  static void Install(v8::Local<v8::Context> context);
};

}
#endif

在 src\content\renderer\ 中新增文件 my_custom_flag.cc

#include "content/renderer/my_custom_flag.h"
#include "base/logging.h"  // NEW: For LOG(INFO)
#include "v8/include/v8.h"
#include "base/command_line.h"
#include "chrome/common/chrome_switches.h"

namespace content {

void myCustomFlagBinding::Install(v8::Local<v8::Context> context) {
  v8::Isolate* isolate = context->GetIsolate();
  v8::HandleScope handle_scope(isolate);

  v8::Local<v8::Object> global = context->Global();

  std::string value;
  auto* command_line = base::CommandLine::ForCurrentProcess();
  if (command_line->HasSwitch(switches::kMyCustomFlag)) {
    value = command_line->GetSwitchValueASCII(switches::kMyCustomFlag);
    LOG(INFO) << "Custom flag detected in renderer: --my-custom-flag=" << value;  // NEW: Enable logging
  } else {
    LOG(INFO) << "No --my-custom-flag found in renderer";  // NEW: Log when switch is missing
  }

// commandFlag 就是新增的全局变量,访问方式就是 window.commandFlag
  global->Set(
      context,
      v8::String::NewFromUtf8(isolate, "commandFlag").ToLocalChecked(),
      v8::String::NewFromUtf8(isolate, value.c_str()).ToLocalChecked())
      .Check();
}

}  // namespace content

将新增的这2个文件,添加到 BUILD.gn 的 target(link_target_type, "renderer") 下的 sources 列表中

在这里插入图片描述在这里插入图片描述

步骤 3:绑定到 RenderFrameImpl,用于测试验证

修改位置:

src\content\renderer\render_frame_impl.cc

新增头文件

#include "content/renderer/my_custom_flag.h"

新增内容

if (world_id == 0) {
  myCustomFlagBinding::Install(context);
}

在这里插入图片描述在这里插入图片描述

步骤 4:确保开关传播到渲染进程

修改位置:

src\content\browser\renderer_host\render_process_host_impl.cc

新增头文件

#include "chrome/common/chrome_switches.h"

在void RenderProcessHostImpl::PropagateBrowserCommandLineToRenderer中新增

switches::kMyCustomFlag,

在这里插入图片描述

在这里插入图片描述 最后,在src目录下,执行 gn gen out/Default ,重新生成构建文件

构建成功之后运行一下命令进行编译

autoninja -C out/Default chrome

使用以下方式启动编译好的chrome.exe

在这里插入图片描述在这里插入图片描述

git push 受阻,原是未拉取代码惹的祸

哈喽大家好,今天跟大家分享一个git操作流程也是新手开发时可能会出现的问题。忘记拉代码,但是自己的代码又写好了,还commit了,如何回溯并提交操作。

问题

开发中,代码已经修改完成,已经git add . 和git commit -m ''。git push失败,发现本地代码不是最新的了。此时应该如何操作会比较好。

解决思路

那么根据上面这个问题,我们可以先想一下标准流程。一般正常在我们开始之前应该是先拉取最新代码,然后再开始。那么此时是不会有冲突的。那么遇到上面这个问题,咱们就是要去把它恢复到那个状态。

解决方案有两种

  1. 重新在一个文件拉取最新代码,然后根据我们左侧的工作区域的修改目录里面内容,找到相对应文件内容,进行复制粘贴修改。(最主要是不敢动,这种方法简单明了,不太会的时候,偷偷含泪使用)

image.png

  1. 使用git的回溯重新提交。

第二种方法的具体实现操作

此时随便拿一个git仓库进行测试,此时文件中只有几个文本文件

image.png

1.在里面加入一个text.txt文件,并上传(建立一个上传记录:第一次上传text)

image.pngimage.png

  1. 查看git log 是否有记录

image.png

  1. 那么此刻我在text.txt中进行修改(我们假设此时的上传是没有拉取最新代码报错的情况)也就是git commit -m ''的操作,但是后续git push没有成功
image.pngimage.png
  1. git log(查看commit记录情况)
image.png

查看我们的提交记录,此时就可以清晰的了解到每次提交命名以及时间

因为我们前面的情况是,已经git commit -m ''这一步了,此时是可以在记录里看到这一步的。

  1. git reset --mixed <当前的上一个log记录>,一个比较重要的操作,回到我们git add .之前,复制第一次上传的text.txt 这条记录的 commit 那串字符(回到了git add .之前)
image.pngimage.png

选中合适的那条记录的的字符串,复制下来,粘贴到git reset --mixed <><>位置(不需要<>符号)

  1. 去保存我们的修改
保存修改
git add . 将修改保存到暂存区
git stash save '起一个名字' 将当前工作区和暂存区的修改保存起来,后续可以恢复(执行到这一步,原代码管理那一块就没有记录了)
git stash list 查看保存的记录,确保保存成功
image.pngimage.png

image.png

7.[这一步一般可以不做] 我们可以回到某一个时间节点(,但是可以学习一下回到之前上传的某个时间节点再去拉取)

回到之前的某个时间节点然后再拉取
git reset --hard <commit_id>()可以不做 通过指定 commit_id 可以强制将本地分支指向该提交<commit_id>
git pull --rebase 会先将远程分支的更新拉取下来,然后将本地提交应用到最新的远程分支上
  1. 取出缓存,此时本地为最新代码,然后将缓存取出来 git stash pop 之前修改的代码会自动加进来

image.png

image.pngimage.png

此时就处于最新代码上修改的一个状态,可以看看,是否有冲突

  1. 最后就正常,git add . / git commit -m '' / git push 提交

总结

本章主要是为了处理,开发过程中,没有拉取最新代码就修改提交无法提交的问题。

简化步骤 此时应该是 git add . / git commit -m '' 情况之后(此时左侧工具栏信息没有,处于同步提交)
git log 查看提交记录,去获取 commit_id
git reset --mixed commit_id 会将暂存区和工作区状态恢复到指定提交(此时左侧工具栏信息恢复)
git add . / git stash save 将当前工作区和暂存区的修改保存起来(此时左侧工具栏信息没有了)
git pull --rebase 拉取最新代码
git stash pop 将之前 git stash save 保存的修改重新应用到工作区(此时左侧工具栏信息恢复,查看是否有冲突)
正常提交 git add . / git commit -m '' / git push

使用CLINE快速生成一个3D展厅

准备工作

Cline 是一款强大的 AI 编程智能体,支持多种编程任务。安装 Cline 的步骤如下:

  1. 打开 VS Code。
  2. 点击左侧边栏的“扩展”图标。
  3. 在搜索框中输入“Cline”并回车。
  4. 在搜索结果中找到 Cline 插件,点击“安装”按钮。

安装完毕后,配置 AI 模型(自己想办法)。

开始干活

输入描述:使用vue3 ts创建一个3D展厅,使用three.js.

image.png

  1. 任务分析:需要创建一个使用Vue3 + TypeScript + Three.js的3D展厅项目

  2. 技术栈选择:

    • Vue3作为前端框架
    • TypeScript作为主要开发语言
    • Three.js用于3D渲染
    • Vite作为构建工具(推荐用于Vue3项目)
  3. 项目初始化步骤:

    • 使用Vite创建Vue3 + TS项目
    • 安装Three.js依赖
    • 创建基础3D场景组件
  4. 需要创建的文件结构:

    • 主入口文件
    • Three.js场景组件
    • 基础3D对象(展厅模型)
  5. 当前目录为空,需要全新初始化项目

npm create vite@latest vue3-3d-gallery -- --template vue-ts

等待交互命令。 下一步

  1. 项目已成功初始化,创建了vue3-3d-gallery目录

  2. 下一步需要:

    • 进入项目目录
    • 安装Three.js依赖
    • 安装其他必要依赖
  3. 需要执行的命令顺序:

    • 进入项目目录
    • 安装three.js和@types/three
    • 安装轨道控制器(OrbitControls)

交互命令

cd vue3-3d-gallery && npm install three @types/three && npm install three-orbitcontrols-ts

报错了:标记“&&”不是此版本中的有效语句分隔符。

  1. 错误分析:Windows PowerShell不支持使用&&进行命令链式执行

  2. 解决方案:

    • 改为分步执行命令
    • 先进入项目目录
    • 然后安装依赖
  3. 需要调整命令执行方式

cd vue3-3d-gallery
  1. 已成功进入项目目录

  2. 现在可以安装Three.js相关依赖

  3. 需要安装的包:

    • three (Three.js核心库)
    • @types/three (TypeScript类型定义)
    • three-orbitcontrols-ts (轨道控制器)
npm install three @types/three three-orbitcontrols-ts

稳定运行中

image.png

搞错了目录 ,npm安装到外面的目录了。 经过简单的修复,就成功运行。

成果展示

整个过程消耗了 $0.0149,还是挺快速的。

image.png

场景很简单,只要地面和一个立方体展品。

  // 添加展厅地面
  const floorGeometry = new THREE.PlaneGeometry(10, 10)
  const floorMaterial = new THREE.MeshStandardMaterial({ 
    color: 0x999999,
    side: THREE.DoubleSide
  })
  const floor = new THREE.Mesh(floorGeometry, floorMaterial)
  floor.rotation.x = -Math.PI / 2
  scene.add(floor)

  // 添加示例展品
  const exhibitGeometry = new THREE.BoxGeometry(1, 1, 1)
  const exhibitMaterial = new THREE.MeshStandardMaterial({ color: 0x00ff00 })
  const exhibit = new THREE.Mesh(exhibitGeometry, exhibitMaterial)
  exhibit.position.y = 0.5
  scene.add(exhibit)

改造升级

项目框架有了。自己可以快速的改造一下,专注场景部分代码的搭建。

最终效果图:

image.png

再附上一张我最初的设计效果:

image.png

看的出来,设计搞和成果各自干各自的,不能说毫无关系,简直是毫无关系。

最后的附上本项目的github地址:github.com/scqilin/3d-….

封装 downloadFile 函数,从服务器下载文件

实现的功能

downFile.js 文件主要实现一个 downloadFile 函数,用于从服务器下载文件。该函数支持多种请求方法(如 getpostFormpostpostFile),并根据不同的请求方法构建请求配置。同时,函数会处理加载状态,根据不同浏览器处理文件下载,还能处理服务器返回的普通对象数据文件流数据

代码

import axios from 'axios';
import qs from 'qs';
import { message } from 'antd';

export default function downloadFile(
  url,
  parmas,
  method = 'get',
  callBackData, // 回调
) {
  // 从响应头中提取文件名
  function getFilenameFromHeaders(headers) {
    const contentDisposition = headers['content-disposition'];
    if (!contentDisposition) return '';
    const [, name] = contentDisposition.split(';');
    const [, fileName] = name.split('filename=');
    return decodeURIComponent(fileName.trim());
  }
  
  // 该函数用于处理文件下载,根据浏览器类型(是否为 IE)采用不同的下载方式。该函数也可以提取为一个单独的文件,比如服务端返回的是json,某些情况才需要下载的时候,可以callBackData拿到数据去做一些判断之后再进行文件下载
  const download = (response) => {
    const filename = getFilenameFromHeaders(response.headers);
    const blob = new Blob([response.data]);
    if (window.navigator && window.navigator.msSaveOrOpenBlob) {
      window.navigator.msSaveOrOpenBlob(blob, filename);
    } else {
      const downloadElement = document.createElement('a');
      const href = window.URL.createObjectURL(blob);
      downloadElement.href = href;
      downloadElement.download = filename;
      document.body.appendChild(downloadElement);
      downloadElement.click();
      document.body.removeChild(downloadElement);
      window.URL.revokeObjectURL(href);
    }
  };

  // 使用对象映射来返回不同请求的config参数
  const methodConfigMap = {
    get: (getUrl, getParmas) => ({
      method: 'get',
      url: `${getUrl}?${qs.stringify(getParmas)}`,
      responseType: 'blob',
    }),
    postForm: (getUrl, getParmas) => ({
      method: 'post',
      url: `${getUrl}`,
      data: qs.stringify(getParmas),
      responseType: 'blob',
    }),
    post: (getUrl, getParmas) => ({
      method: 'post',
      url: `${url}`,
      data: { ...getParmas },
      responseType: 'blob',
    }),
    postFile: (getUrl, getParmas) => ({
      method: 'post',
      url: `${url}`,
      data: getParmas,
      responseType: 'blob',
    }),
  };
  const config = methodConfigMap[method]?.(url, parmas) || {};
  axios(config)
    .then((response) => {
      const reader = new FileReader();
      reader.readAsText(response.data, 'utf-8');
      reader.onload = (e) => {
        try {
          // 是普通对象数据
          const jsonData = JSON.parse(e.target.result);
          callBackData && callBackData(jsonData);
          message.error(jsonData.message || jsonData.msg);
        } catch (err) {
          // 是流文件直接下载
          callBackData && callBackData();
          download(response);
        }
      };
    })
    .catch((err) => {
      message.error(err.message); 
    });
}


应用场景

比如业务中的批量导出,上传文件之后服务端返回给前端的结果为文件流(这里可能是上传结果异常数据)。

使用实例

  1. 直接通过服务端提供的url下载
import downloadFile from '@utils/downFile';

    // 批量导出
    const batchExport = useCallback(() => {
      const url = 'api/export.do';
      // 有选中的数据导出选中的数据,没有选中的数据导出全部
      const param = { id, detailIds: selectedkeys.length === 0 ? '' : selectedkeys.join(',') };
      downloadFile(url, param, 'postForm');
    }, [dispatch, id, selectedkeys]);

2.上传文件之后,服务端直接返回一个数据流或者json,不另外提供下载接口

  const callBackData = useCallback(
    (data) => {
      setImporting(false);
      closeFun();
      reloadPage();
      // 这里可以对data做一些判断,
      // 或者是前端把文件暂存到变量,展示一个结果弹窗,让用户点击的时候执行download函数并把暂存的文件流作为参数传递给download函数
      if (data?.code === 900) {
        message.success('导入成功');
      }
    },
    [closeFun, reloadPage],
  );

  // 导入(上传)
  const importFile = useCallback(() => {
    setImporting(true);
    const params = new FormData();
    params.append('file', fileInfo);
    downloadFile(
      'api/batchImportToPaid.do',
      params,
      'postFile',
      callBackData,
    );
  }, [callBackData, fileInfo]);

PYLSP 桥接 MONACO

背景

笔者需要在本地搭建一个支持 python 代码进行补全,格式化,报错提示等功能的 web idea。 目前项目已经基于 monaco 完成了一个对 sql 代码进行处理的 idea,于是初步定下了桥接 pylsp 和 monaco 实现 python 编辑器的技术方案。

搭建 python-lsp-server ws 服务

目标:基于pylsp服务和ngxin,实现一个服务能够和 monaco 进行通信,完成基础功能

准备

python3.8.2 并编译好 pip。

网上很多 python 环境搭建的资料可供学习,此处不详表

下载 python-lsp-server 服务

pip3 install python-lsp-server /服务下载/ pip3 install python-lsp-server[all] /下载所需插件/

pylsp 下载成功后,执行 pylsp --version 正确输出 pylsp 版本即下载成功。

注:在格式化功能中 pylsp,black, python-lsp-black 三个版本号需要相互兼容 我使用的版本号是 pylsp v1.7.4 black 22.8.0 python-lsp-black 1.1.2

启动服务

pylsp 启动命令: pyslp --ws --host [your host] --port [your port] --log-file [your log file path] -v

低版本 pylsp 没有 --ws 命令,则需要搭建一个服务轮询和 pylsp 服务进行通信, 是否支持直接搭建 ws 服务可以通过 pylsp -h 查看输出结果中是否含有 --ws.

配置 pylsp.service

笔者服务器是 centos7 创建文件如下:

[Unit]

Description=Python Language Server Protocol(pylsp)

After=network.target

[Service]

Type=simple

User=root

ExecStart=[pylsp path] --ws --host [your host] --port [your port] --log-file [your log file path] -v
Restart=on-failure
RestartSec=5

[Install]
wantedBy=multi-user.target

启动服务

systemctl daemon-reload // 使pylsp.service 生效

systemctl start pylsp.service // 启动 pylsp 服务

systemctl enable pylsp.service // 设置 pylsp 服务开机自启

systemctl statuus pylsp.service // 检查 pylsp 服务状态

返回结果中包含 active 即为启动成功

配置NGINX

笔者已经配置好了服务,目前是基于写好的配置上新增

server {
  listen 443 ssl;
  server_name [your server];
  
  /* 非空,是已经写好的转发配置 */
  location /ws {
    proxy_pass `http://${your host}:${your port}`;
    proxy_http_version 1.1; // 这里不能掉,默认的访问会被 ws 服务拒绝
    proxy_set_header Upgrade "upgrade";
    proxy_set_header Connection "upgrade"
  }
}

monaco web 端对接 pylsp 服务

创建 socket 服务,开始和 pylsp 服务通信

// 如果是直接通过 http 通信则有 WS_ENDPOINT = `ws://[yourhost]:[your port]` 即可
const WS_ENDPOINT = `wws://[your server]/ws`;
const socket = new WebSocket(WS_ENDPOINT);
const pendingPromises = new Map(); // 通过这个 map 包裹待响应的socket请求,用于后续直接链式调用 socket 请求
const rootUri = "mem:///" // web idea 可供pylsp 服务访问的文件实例,则通过这个路径告知服务,是虚拟路径
const requestId = 1;
const useId = getUSerId(); // 编辑器支持多人协同编辑,则请求id 和 userId 关联, 避免并发请求出错

const sendRequesPromise = (request, timeout = 5000) => new Promise((resolve, reject) => {
  const {id} = request;
  pendingPromises.set(`${userId}_${id}`, {resolve, reject});
  socket.send(JSON.stringify(request));
});

// 处理 LSP 服务响应
socket.onmessage = (event) => {
  const response = JSON.parse(event.data);
  const {id} = response;
  const socketId = `${userId}_{id}`;
  if(pendingPromises.has(socketId)) {
    const {resolve, reject} = pendingPromises.get(socketId);
    response.error ? reject(response.error) : resolve(response);
    pendingPromises.delete(socketId);
  }
  if(resopnse.method === 'textDocument/publishDiagnostics') setDiagnosticsMarkets(response.params.diagnostics);
};

// 初始化 LSP 服务
const initLSPServer = () => new Promise(resolve) => {
  const initRequest = {
    jsonrpc: "2.0",
    id: requestId,
    method: "iniialize",
    params: {
      procesId: requestId,
      rootUri,
      capabilities: {
        textDocument: {
          synchronization: { 
            dynamicRegistration: true
          },
          completion: {
            completionItem: {
              snippetSupport: true
            }
          },
          formatting: {
            dynamicRegistration: true
          }
        }
      }
    }
  };
  sendRequestPromise(initRequest).then(res => resolve(res));
};

// 告知 LSP, 某.py文件已打开
const initOpenFile = () => {
  const text = editor.getValue(); // editor 即为 monaco 编辑器实例
  const fileName = getFileName(); // file Name 即为文件名称,涉及到协同编辑时,则fileName 拼上userId
  const didOpenMsg = {
    jsonrpc: "2.0",
    method: "textDocument/didOpen",
    params: {
      textDocument: {
        languageId: "python",
        version: 1,
        text: text,
        uri: `${rootUri}${fileName}` // 后续执行补全,报错诊断,格式化 lsp服务都需要依赖此Id获取文件实例
      }
    }
  }
};

socket.onopen = () => {
  initLSPServer.then(() => initOpenFile());
}
// 将此函数绑定到 monaco 的 change 事件上
const onEditorChange = () => {
  const text = editor.getValue();
  const textDocument = {
    uri: fileUri,
    id: requsetId,
    languageId: "python",
    version: 1,
    text: text
  };
  const didChangeMsg = {
    jsonrpc: "2.0",
    method: "textDocument/didChange",
    params: {
      textDocument: textDocument,
      contentChanges: [{text: text}]
    }
  };
  // 告知lsp服务,文件内容变动
  socket.send(JSON.strigify(didChangeMsg))
};

// pylsp 发起获取报错信息的请求,返回信息中没有具体的报错信息,因此通过 socketId 拿到返回信息添加报错装饰不可取
const getDiagnostics = () => {
  const text = editor.getValue();
  const textDocument = {
    uri: fileUri,
    id: requsetId,
    languageId: "python",
    version: 1,
    text: text
  };
  
  const diagnosticsMsg = {
    id: requestId,
    jsonrpc: "2.0",
    method: "textDocument/documentSymbol",
    params: {
      textDocument: textDocument
    }
  };
  sendRequestPromise(diagnosticsMsg);
};

const setDiagnosticsMarkets = (diagnostics) => {
  if(!diagnostics || diagnostics.length === 0) {
    const markers = diagnostics.map(d => ({
      message: d.message,
      severity: monaco.MarkerSeverity.Error, // 根据 d 返回可以分级处理报错信息
      startLineNumber: d.range.start.line + 1,
      endLineNumber: d.range.end.line + 1,
      startColumn: d.range.start.character + 1,
      endColumn: d.range.end.character + 1
    }));
    monaco.edditor.setMoedlMarkers(editor.getModel, 'python', markers);
  }
};

网页图像优化:现代格式与响应式技巧

网页图像优化:现代格式与响应式技巧

网页图像如果处理不好,很容易拖慢加载速度,影响用户体验。这篇文章聊聊怎么用现代图像格式和响应式技巧,让你的网站图片加载更快、效果更好。


推荐的图像格式

选对图像格式,能在保证质量的同时尽量缩小文件大小。以下是两种主流选择:

  • WebP:几乎所有浏览器都支持,压缩效果很强,文件小、画质也不错,适合大多数场景。
  • AVIF:新出的格式,压缩比 WebP 还牛,文件更小,画质也很顶。不过现在浏览器支持还没完全普及。

压缩工具

想让图片再小点,可以试试在线工具:


打破请求链:让图片加载更快

有时候,图片加载慢是因为它得等其他东西先加载完,这种情况叫请求链。比如,通过 JavaScript 加载图片时,浏览器得:

  1. 先把 HTML 下载下来。
  2. 再下载 JavaScript(有时候还会卡住页面渲染)。
  3. 然后解析、执行 JavaScript(老设备上可能更慢)。
  4. 最后才开始下载图片。

这整个链条会拖慢图片显示。想解决?试试这几招:

1. 直接用 <img> 标签

别用 JavaScript 动态加载图片,直接在 HTML 里写 <img> 标签,简单粗暴:

<img src="image.jpg" alt="图片描述" />

2. 用 <link rel="preload"> 提前加载

在 HTML 头部加个预加载提示,让浏览器早点开始下图片:

<link rel="preload" as="image" href="image.jpg" />

3. 懒加载(Lazy Loading)

对于不在屏幕里的图片,可以用懒加载,推迟加载,省点带宽:

<img loading="lazy" src="image.jpg" alt="图片描述" />

4. 提高优先级

有些关键图片想快点显示?加个 fetchpriority="high",让浏览器优先处理:

<img src="image.jpg" alt="图片描述" fetchpriority="high" />

响应式图片:适配不同设备

响应式图片就是根据设备屏幕大小,加载合适的图片尺寸,省带宽、提速度。用 <img>srcsetsizes 属性就能搞定。

示例代码

<img
  src="small.jpg"
  srcset="small.jpg 300w, medium.jpg 600w, large.jpg 900w"
  sizes="(max-width: 500px) 300px,
         (max-width: 900px) 600px,
         900px"
  alt="图片描述" />

代码啥意思?

  • srcset:列出不同尺寸的图片和它们的宽度(比如 300w 就是宽度 300 像素的图)。
  • sizes:告诉浏览器根据屏幕宽度选哪张图。比如,屏幕小于 500px 时,选 300px 的图。
  • src:给不支持 srcset 的老浏览器一个默认图片。

这么干,设备就不会浪费带宽去下过大的图片,加载速度自然快。


总结

想让网页图片加载快、效果好,记住这几点:

  1. 用现代格式:WebP 或 AVIF 效果好,配合 Squoosh 压缩更省空间。
  2. 优化加载:直接用 <img>、预加载、懒加载、设置优先级,打破请求链。
  3. 响应式设计:用 srcsetsizes 让图片适配不同设备,省带宽。

这些方法能让你的网站在手机和慢网速下也跑得飞快。赶紧试试,优化一下你的图片加载吧!

前端BFC详解:从基础到深入的全面解读

在前端开发中,BFC(Block Formatting Context)是一个至关重要的概念,它直接影响到网页的布局和渲染。理解BFC不仅可以帮助开发者解决布局问题,还能提高代码的可维护性和可读性。本文将从基础概念出发,逐步深入,全方位解读BFC的原理、特性及其在实际开发中的应用。

一、什么是BFC?

BFC,即Block Formatting Context,中文译为“块级格式化上下文”。它是一个独立的渲染区域,决定了内部元素如何布局以及与外部元素的关系。简单来说,BFC是一个独立的盒子,内部的元素按照BFC的规则进行布局,而外部的元素则不受BFC内部布局的影响。

二、BFC的创建方式

BFC可以通过以下几种方式创建:

  1. 根元素:HTML文档的根元素<html>会自动创建一个BFC。
  2. 浮动元素:使用float属性且不为none的元素会创建一个新的BFC。
  3. 绝对定位元素:使用position属性为absolutefixed的元素会创建一个新的BFC。
  4. 行内块元素:使用display属性为inline-block的元素会创建一个新的BFC。
  5. 表格单元格:使用display属性为table-cell的元素会创建一个新的BFC。
  6. 表格标题:使用display属性为table-caption的元素会创建一个新的BFC。
  7. 块级元素:使用display属性为block的元素会创建一个新的BFC。
  8. 溢出内容:使用overflow属性不为visible的元素会创建一个新的BFC。
  9. Flex容器:使用display属性为flexinline-flex的元素会创建一个新的BFC。
  10. Grid容器:使用display属性为gridinline-grid的元素会创建一个新的BFC。

三、BFC的特性

理解BFC的特性是掌握BFC的关键。以下是BFC的主要特性:

  1. 内部的Block-level盒子在垂直方向上一个接一个地排列,盒子之间垂直方向的距离由margin决定。
  2. 内部的元素不会影响外部的元素。
  3. 清除浮动:BFC会包含内部的浮动元素,防止浮动元素影响外部布局。
  4. 避免margin重叠:BFC内部的元素之间的margin不会重叠。
  5. BFC是页面上的一个隔离的独立容器,容器里面的子元素不会影响到外面的元素,反之亦然。
  6. 计算BFC的高度时,浮动元素也参与计算。

四、BFC在实际开发中的应用

了解了BFC的特性后,我们来看看它在实际开发中的应用。

1. 清除浮动

浮动元素可能会导致父元素的高度塌陷,这时我们可以利用BFC来解决这个问题。例如,给父元素添加overflow: hidden;可以创建一个新的BFC,从而包含浮动的子元素,防止高度塌陷。

.parent {
    overflow: hidden; /* 创建BFC */
}
.child {
    float: left;
}

2. 避免margin重叠

当两个垂直相邻的Block-level元素都有margin时,它们的margin会重叠,导致实际的间距小于预期。通过创建BFC,可以避免这种margin重叠。

.element {
    overflow: auto; /* 创建BFC,避免margin重叠 */
    margin-top: 20px;
    margin-bottom: 20px;
}

3. 解决元素包裹问题

在某些情况下,元素可能会因为浮动或其他布局原因而无法正确包裹内容。通过创建BFC,可以确保元素正确包裹其内容。

.wrapper {
    overflow: hidden; /* 创建BFC,确保包裹内容 */
}
.float-element {
    float: left;
}

4. 实现复杂的布局

BFC还可以用于实现复杂的布局,如两栏布局、三栏布局等。通过合理地创建BFC,可以更容易地控制元素的排列和对齐。

.container {
    overflow: hidden; /* 创建BFC */
}
.left-column {
    float: left;
    width: 30%;
}
.right-column {
    float: right;
    width: 70%;
}

五、BFC的注意事项

虽然BFC在布局中非常有用,但也有一些需要注意的地方:

  1. 性能问题:创建过多的BFC可能会对页面性能产生影响,因为浏览器需要额外的计算来处理BFC。
  2. 兼容性问题:不同的浏览器对BFC的支持可能存在差异,需要进行充分的测试。
  3. 过度使用:不要滥用BFC,应该根据实际需求合理使用,避免不必要的复杂性。

六、总结

BFC是前端开发中一个非常重要的概念,它决定了元素的布局和渲染方式。通过理解BFC的创建方式、特性及其在实际开发中的应用,开发者可以更好地控制页面布局,解决常见的布局问题。

构建交互网站

在本单元中,你将学习如何使用 JavaScript 构建交互式网站。

本单元的目标是了解 JavaScript 如何为网站添加交互体验。

完成本单元后,你将能够:

  • 为网站添加 JavaScript 以实现交互功能
  • 描述什么是 DOM
  • 解释什么是 DOM 事件
  • 使用 HTML 创建表单,并用 JavaScript 对其进行验证

JavaScript and HTML

HTML 使用页面元素作为构建模块来定义网页的结构。然而,仅靠 HTML 并不能实现网页的交互功能,这正是 JavaScript 发挥作用的地方。

下面我们看到一张便签纸,上面画着一个典型的小火柴人。我们可以把它看作 HTML,其中的头部、身体和四肢就像网页上的元素:

image.png

在网页开发中,CSS 为 HTML 的结构添加样式。如下图所示,小火柴人现在穿上了一套漂亮的礼服:

image.png

如果说 HTML 和 CSS 分别提供了网页的“结构”和“样式”,那么 JavaScript 则赋予了网页“交互性”,让小火柴人动起来。下面,小火柴人开始上下摆动,这就是 JavaScript 的功劳:

网页开发者使用 JavaScript 让网页变得动态且具有交互性。这种强大的脚本语言通过一个专属的 HTML 元素来嵌入页面:<script> 元素。你可以把 <script> 元素看作 HTML 连接 JavaScript 的“门户”。本课将深入讲解 <script> 元素在网页中能发挥什么作用,以及在 HTML 文件中插入 JavaScript 的最佳实践。

script标签

<script> 元素允许你在 HTML 文件中添加 JavaScript 代码。下面的例子展示了如何使用 <script> 元素嵌入合法的 JavaScript 代码:

<h1>This is an embedded JS example</h1>
<script>
  function Hello() {
    alert('Hello World');
  }
</script>

说实话,没有 <script> 标签,网站将变得无法点击,甚至有些无趣。

<script> 元素和大多数 HTML 元素一样,有一个开始和结束的尖括号(标签)。结束标签标志着 <script> 元素中内容的结束。就像我们用 <style> 标签嵌入 CSS 代码一样,你可以使用 <script> 标签嵌入合法的 JavaScript 代码。

例如:

<!DOCTYPE html>
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
  
<body>
<section class = "container">
  <img src = "normal.png" id= "myImage">
  <p onclick="blooming()">Codecademy</p>
</section>

  <!-- Paste your code in the script element below:  -->
  <script>
  function blooming() {
  var image = document.getElementById('myImage');
  if (image.src.match("normal")) {
    image.src = "flower.png";
  } else {
    image.src = "normal.png";
  }
}

  </script>
</body>  
</html>

src属性

既然你已经知道如何在 <script> 元素中嵌入代码,现在我们来说说“链接”代码。链接代码更受欢迎,是因为一个编程概念:关注点分离(Separation of Concerns,简称 SoC)。与其把所有混乱的代码都写在一个文件里,Web 开发者更倾向于将代码拆分到不同的文件中,这样每一部分的“职责”就更清晰,也更方便在需要更改时进行维护。

在本练习中,我们不会在 HTML 文件里写 JavaScript,而是会把它写进自己的文件中,然后通过文件路径来引用这段代码。我们会用到一个你可能已经熟悉的属性:src 属性!

如果你觉得这个属性眼熟,那是因为你可能已经用 <img><link> 元素链接过外部文件了。这个属性的用法是一样的,只不过这次它的值指定的是脚本文件的位置。

如果脚本文件和 HTML 文件在同一个项目文件夹中,src 的值就会是一个相对路径名。下面是一个为 JavaScript 文件提供相对路径的例子:

<script src="./exampleScript.js"></script>

上面的 <script> 标签会去查找一个名为 exampleScript.js 的文件,该文件应当和 index.html 文件位于同一文件夹或目录中。

如果你需要引用托管在外部(如 CDN)上的 JavaScript 文件,你也可以将 src 设置为该文件的完整链接地址。

script是如何加载的?

快速回顾一下:<script> 元素允许 HTML 文件加载并执行 JavaScript。JavaScript 可以直接写在 <script> 标签内部,也可以通过引用外部文件来加载脚本。在深入了解之前,我们先花点时间了解一下浏览器是如何将 HTML 文件解析为网页的,这会帮助我们明白该把 <script> 标签放在哪里。

浏览器内置了 HTML 解析器,用来帮助浏览器按照规则渲染网页中的各个元素。HTML 文件中的元素(包括 <script> 元素)默认是按照它们在 HTML 文件中出现的顺序进行解析的。当解析器遇到一个 <script> 元素时,它会先加载并执行该脚本的内容,然后才继续解析 HTML 的其余部分。

这里有两个重点要注意:

  1. HTML 解析器在加载并执行 <script> 元素之前,不会处理下一个 HTML 元素,这会造成网页加载的延迟,从而带来不好的用户体验。
  2. 脚本是按顺序加载的,所以如果某个脚本依赖于另一个脚本,那么它们在 HTML 中的顺序也必须正确。

defer属性

当 HTML 解析器遇到一个 <script> 元素时,它会暂停解析 HTML,去加载该脚本的内容。脚本加载完成后,JavaScript 代码会立即执行,然后 HTML 解析器才会继续处理接下来的内容。这种行为可能导致网页加载速度变慢。

为了解决这个问题,HTML4 引入了 <script> 元素的两个新属性:deferasync,它们可以根据不同的场景来优化网站的加载体验,减少用户等待时间。

defer 属性的作用是:在 HTML 文件完全解析完成之后再执行脚本。当 HTML 解析器遇到带有 defer 属性的 <script> 元素时,它会开始加载脚本文件,但不会立刻执行,而是等到整个 HTML 都解析完毕后再执行该脚本。

下面是一个使用 defer 属性的例子:

<script src="example.js" defer></script>

当脚本中包含需要与 DOM 交互的功能时,使用 defer 是非常合适的。这样可以确保整个 HTML 文件已经解析完成,脚本执行时 DOM 是完整可用的。

async属性

async 属性可以异步加载并执行脚本,与网页的其他部分同时进行。也就是说,与 defer 属性类似,HTML 解析器在加载脚本的同时还会继续解析 HTML 页面,不会暂停等待脚本加载完成。

但与 defer 不同的是,使用 async 属性时,脚本在下载完成后就会立刻执行不等 HTML 页面完全解析完成

下面是一个使用 async 属性的例子:

<script src="example.js" async></script>

async什么时候用?

async 非常适用于不依赖其他脚本,也不需要等待 DOM 加载完成的脚本,比如第三方广告、数据统计工具(如 Google Analytics)等。

如果脚本的执行时机无所谓,并且它是独立的、不影响页面主功能,那么使用 async 是最合适的方式,可以有效优化网页加载速度

总结:

  • HTML 构建网页的骨架,但 JavaScript 引入了交互性

  • <script> 元素有开始标签和结束标签。你可以在这两个标签之间嵌入 JavaScript 代码。

  • 如果要链接外部的 JavaScript 文件,可以在 <script> 标签的开始标签中使用 src 属性

  • 默认情况下,浏览器的 HTML 解析器在遇到 <script> 标签时,会立即加载并执行其中的脚本,然后才继续解析页面中剩余的元素。

  • 使用 defer 属性,可以确保整个 HTML 文件解析完成后再执行脚本。

  • 使用 async 属性,浏览器在下载脚本的同时会继续解析 HTML 页面,一旦脚本下载完成就立即执行,而不是等待页面解析完。

  • 旧的做法是将 <script> 标签放在 </body> 标签之前,以防止脚本阻塞页面加载。现在的推荐做法是将 <script> 标签放在 <head> 中,并结合使用 deferasync 属性。

dom

什么是dom

dom = document object model

dom是浏览器对网页的内部表示,每个页面都依赖于dom来准确描述页面上的内容,以及这些内容与其他内容之间的关系。

web page = document

网页简称为文档,文档一词实际上指的是任何构建信息的方式,书籍、文章、论文等等。但从文档开发的角度来看,文档是网页的另一种说法,因此,dom是网页或文档中所有内容的模型。

网页上的内容就是对象,至少dom是这么称呼它们的。你可能听说过节点或者元素,它们用来描述网页上的内容,这些都是对象的类型,但这些对象到底是什么呢?

网页上最明显的东西就是内容,每个页面都有文字内容,还包括图片、视频、按钮等,所有这些都被视为对象。你可能也听说过这些东西被称为元素,它是一种特定的对象。

另一种东西是结构元素,比如div容器,你可能真的能看到网页上所有的结构对象,也可能看不到,但它们是组织内容所必需的。

剩下的东西主要是属性,每个元素都具有属性,例如html元素可以有类、样式、大小等。所有这些属性都是文档对象模型中的对象,但它们不是像内容或者结构这样的元素。

model:某种东西的表示,它可以帮助我们理解某种东西是如何组合在一起的。你能想象到的任何东西都有模型,因为复杂的东西需要某种方式来被普遍理解、分析和使用。

dom是网页的模型,是一种对文档中的对象进行建模的方法。或者更简单的说,dom代表网页上的元素和属性。

dom是一种树形数据结构,这意味着每个对象都在另一个对象下有层次。一个对象可以有多个子对象,但只能有一个父对象。当我们讨论父对象和子对象时,我们指的是对象之间的关系。

父对象拥有其子对象列表,如果从这棵树上移除任何对象,它的所有子对象,子对象的子对象都会被移除

image.png

image.png

浏览器通过实现 DOM,使得 JavaScript 可以以有组织的方式访问、修改和更新 HTML 网页的结构。

因此,我们可以把 DOM 看作 HTML 网页与脚本语言之间的桥梁。

image.png

dom作为树形结构

树状建模在许多领域中都有应用,包括进化科学和数据分析。也许你已经熟悉“家谱”这一概念:它通过图表来展示某个家族中后代之间的亲属关系。

DOM 树的逻辑与家谱图类似。家谱由家庭成员及其与家族姓氏的关系构成。在计算机科学中,我们会将每个家庭成员称为一个“节点”。

我们将“节点”定义为树状结构中的交叉点,每个节点都包含数据。

在 DOM 树中,最顶层的节点被称为“根节点”(root node),它代表整个 HTML 文档。根节点的后代是文档中的 HTML 标签,从 <html> 标签开始,接着是 <head><body> 标签,依此类推。

image.png

父节点(parent node) :指的是某个节点的直接上级节点,也就是它的祖先之一。

子节点(child node) :指的是某个节点的直接下级节点,其上级被称为父节点。

了解这些术语有助于你理解并讨论 DOM 这种树状结构。事实上,当我们讨论 HTML 代码的嵌套结构时,也会使用这些术语。程序员通常将嵌套在其他元素内部的元素称为“子元素”,而包含它们的元素称为“父元素”。

dom中的节点和元素

如前所述,节点就相当于家谱中的每一个家庭成员。节点是树状结构中的一个交叉点,同时也包含数据。

在 DOM 树中存在多种类型的节点对象。在我们的示意图中,带有尖角矩形的表示元素节点(Element nodes) ,而带有圆角矩形的表示文本节点(Text nodes) ,因为它们代表的是 HTML 段落元素中的文字内容。

在修改网页时,脚本通常主要与 DOM 中的元素节点交互,有时也会操作文本节点

image.png

元素节点的属性

DOM 中的元素节点用于模拟 HTML 文档中的各个元素。

就像 HTML 页面中的元素一样,DOM 允许我们访问节点的各种属性,比如它的 classid 和行内样式(inline style)。

在右侧的示意图中,我们高亮显示了 HTML 文档中一个 id'bio' 的段落元素。如果我们在脚本中访问这个元素节点,DOM 就可以让我们修改这些属性,或者直接读取它们的值以供代码使用。

image.png

总结

让我们回顾一下你目前学到的内容:

  • DOM 是网页的结构化模型,它允许脚本语言访问该页面。
  • DOM 的组织系统模仿了 HTML 文档的嵌套结构。
  • 被嵌套在某个元素中的元素称为该元素的子元素,而包含它们的元素则被称为它们的父元素
  • DOM 还允许访问 HTML 元素的各种属性,比如 styleid 等。

通过这些知识,你就可以开始运用脚本语言的强大功能来创建更新维护网页啦!

image.png

修改元素

在脚本中使用 DOM 访问某个 HTML 元素时,无论是一个 <li> 元素,还是整个 <body> 元素,你都可以访问该元素的所有属性。

这意味着你可以修改该元素的内容、属性以及其他特性,比如更改 <p> 元素中的文字,或为 <div> 元素设置新的背景颜色。例如,.innerHTML 属性可以让你访问或设置一个元素的内容。

来看一个例子,我们将 <body> 元素的内容重新赋值为文本 'The cat loves the dog.'

document.body.innerHTML = 'The cat loves the dog.';

.innerHTML 属性也可以插入任何合法的 HTML 元素。下面这个例子将 <body> 元素的内容替换为一个 <h2> 元素,也就是给 <body> 添加了一个子元素:

document.body.innerHTML = '<h2>This is a heading</h2>';

选择并修改元素

在上一个练习中,我们使用 document 关键字访问了 <body> 元素!

那么,如果我们想选择 <body> 之外的某个特定元素该怎么办呢?DOM 接口允许我们使用 CSS 选择器 来访问特定的元素。

CSS 选择器通常用于定义哪些元素应用某组 CSS 样式,但我们也可以使用这些相同的选择器,通过 JavaScript 来访问 DOM 元素!选择器可以是标签名、类名,或 ID。

.querySelector() 方法允许我们将一个 CSS 选择器作为字符串传入,并返回匹配该选择器的第一个元素。下面的代码将返回文档中的第一个段落元素

document.querySelector('p');

除了 .querySelector() 之外,JavaScript 还提供了更多有针对性的方法,允许我们根据元素的类名、ID 或标签名来选择元素。

例如,如果你想通过元素的 ID 直接访问它,可以使用命名直观的 .getElementById() 方法:

document.getElementById('bio').innerHTML = 'The description';

在这个例子中,我们选中了 ID 为 'bio' 的元素,并将它的 .innerHTML 设置为 'The description'。注意,这里的 ID 是以字符串形式传入的(用引号括起来)。

此外,还有 .getElementsByClassName().getElementsByTagName() 方法,这两个方法返回的是一个元素数组,而不是单个元素。你可以使用中括号([])来访问数组中的某个具体元素:

// 将第一个 class 为 student 的元素内容设置为 'Not yet registered'
document.getElementsByClassName('student')[0].innerHTML = 'Not yet registered';

// 将第二个 <li> 标签的内容设置为 'Cedric Diggory'
document.getElementsByTagName('li')[1].innerHTML = 'Cedric Diggory';

在上面的示例中,我们修改了第一个类名为 'student' 的元素和第二个 <li> 元素的 HTML 内容。

给元素设置样式

另一种修改元素的方法是更改它的 CSS 样式。DOM 元素的 .style 属性提供了对该 HTML 标签内联样式的访问。

语法遵循 element.style.property 的格式,其中 property 表示一个 CSS 属性。例如,以下代码选择了第一个类名为 blue 的元素,并将其 background-color 设置为蓝色:

let blueElement = document.querySelector('.blue');
blueElement.style.backgroundColor = 'blue';

与 CSS 不同的是,DOM 中的 .style 属性不使用连字符(如 background-color),而是使用驼峰式命名法(camelCase),即 backgroundColor。你可以参考 W3C 的 HTML DOM Style 对象文档,了解各种 CSS 属性在 JavaScript 中是如何转换的。

下面这种链式写法也是可以的:

document.querySelector('.blue').style.fontFamily = 'Roboto';

遍历 DOM

让我们来回顾一下 DOM 层级结构中的父子关系:

  • 父节点(parent node) 是某个节点的直接祖先;
  • 子节点(child node) 是某个节点的直接后代,该节点称为该子节点的父节点。

这种关系遵循 HTML 代码的嵌套结构:如果某个元素嵌套在另一个 HTML 元素中,那么它就是该元素的子元素。

每个元素都有 .parentNode.children 属性:

  • .parentNode 返回指定元素在 DOM 层级中的父节点。注意:document 元素是根节点,因此它的 .parentNode 返回 null
  • .children 返回该元素的所有子节点组成的 类数组对象(不是标准数组)。如果该元素没有任何子节点,则返回 null

来看下面这段 HTML:

<ul id='groceries'>
  <li id='must-have'>Toilet Paper</li>
  <li>Apples</li>
  <li>Chocolate</li>
  <li>Dumplings</li>
</ul>

在上面的代码中,有一个 ID 为 groceries<ul> 元素,内部包含了四个 <li> 元素。

对应的 JavaScript 代码如下:

let parentElement = document.getElementById('must-have').parentNode; 
// 返回 <ul> 元素

let childElements = document.getElementById('groceries').children; 
// 返回一个包含四个 <li> 元素的“类数组”

解释:

  • parentElement 获取的是 ID 为 must-have<li> 元素的父节点,也就是 ID 为 groceries<ul> 元素;
  • childElements 获取的是 ID 为 groceries<ul> 元素的子节点们,即四个 <li> 元素。

创建并插入元素

就像 DOM 允许脚本修改已有元素一样,它也允许创建新的元素。

.createElement() 方法可以根据传入的标签名参数创建一个新的元素。不过,它不会自动将这个元素添加到文档中。它只会创建一个没有内部 HTML 内容的空元素。

let paragraph = document.createElement('p');

在上面的示例代码中,.createElement() 方法传入 'p' 作为参数,这将创建一个空的 <p> 元素,并将其赋值给变量 paragraph

我们可以像以前修改已有元素一样,为新创建的元素设置属性:

paragraph.id = 'info'; 
paragraph.innerHTML = 'The text inside the paragraph';

上面的代码中,我们使用 .id 属性将 'info' 设置为这个元素的 ID,并使用 .innerHTML 属性设置 <p> 元素的内容为 'The text inside the paragraph'

为了将新创建的元素添加到网页中,必须将其指定为一个已存在的 DOM 元素的子元素,我们称这个已有元素为“父元素”。这个过程称为“追加”。.appendChild() 方法会将子元素添加为父元素的最后一个子节点。下面的代码将存储在 paragraph 变量中的 <p> 元素追加到 document.body 上:

document.body.appendChild(paragraph);

.appendChild() 方法不会替换父元素(在这个例子中是 body)中的内容,而是将新元素追加为该父元素的最后一个子元素。

移除元素

除了可以修改或从头创建一个元素,DOM 还允许我们移除某个元素。.removeChild() 方法可以从父元素中移除指定的子元素。

let paragraph = document.querySelector('p');
document.body.removeChild(paragraph);

在上面的示例代码中,.querySelector() 方法返回文档中的第一个 <p> 元素。然后,这个 paragraph 元素作为参数传递给其父元素(document.body)调用的 .removeChild() 方法,从而将这个段落从文档的 <body> 中移除。

如果你只是想隐藏一个元素,而不是完全删除它,可以使用 .hidden 属性。通过将这个属性设置为 truefalse,可以控制元素的显示与隐藏:

document.getElementById('sign').hidden = true;

上面的代码并没有从 DOM 中移除 ID 为 'sign' 的元素,而是将它隐藏了起来。

添加点击交互

你可以通过为 DOM 元素指定一个函数,在特定事件发生时运行,从而为它添加交互功能。事件可以包括点击、鼠标悬停等操作。我们将在后面的“使用 JavaScript 操作 DOM 事件”课程中更深入地了解事件的使用。现在,我们先来看一下在点击事件发生时如何修改一个元素。

.onclick 属性允许你为元素指定一个在点击事件发生时执行的函数:

let element = document.querySelector('button');

element.onclick = function() { 
  element.style.backgroundColor = 'blue'; 
};

你也可以将 .onclick 属性赋值为一个已命名的函数:

let element = document.querySelector('button');

function turnBlue() {
   element.style.backgroundColor = 'blue';
}

element.onclick = turnBlue;

在上面的示例代码中,当 <button> 元素检测到点击事件时,其背景颜色会变成 'blue'

总结

在本节课中,你通过 JavaScript 的 DOM(文档对象模型)接口操作了网页的结构。

我们来回顾一下学到的内容:

  • document 关键字可以让你访问 JavaScript 中 DOM 的根节点。
  • DOM 接口允许你使用 .querySelector() 方法,通过 CSS 选择器选中一个特定的元素。
  • 你可以使用 .getElementById() 方法直接通过元素的 ID 获取该元素,该方法返回一个单独的元素。
  • 你可以使用 .getElementsByClassName().getElementsByTagName() 方法获取一个元素的数组,然后通过数组下标来引用其中的某个元素。
  • .innerHTML.style 属性分别可以修改元素的内容和样式。
  • 你可以使用 .createElement().appendChild().removeChild() 方法分别来创建、添加和移除元素。
  • .onclick 属性可以为 DOM 元素添加点击事件,实现交互功能。
  • .children 属性返回一个元素的所有子元素列表,而 .parentNode 属性则返回该元素向上查找的最近的父节点。

这些知识点可以帮助你用 JavaScript 动态地操作网页内容和结构,打造更加丰富的用户体验。

js的dom事件

什么是事件

当你刷新邮箱、双击帖子,或滚动浏览新闻推送时——浏览器里就会发生一些很酷的事情。这些操作就叫做“事件”!

网页上的事件是指用户的交互行为或浏览器的动作,你可以通过编程方式来响应这些事件,从而触发相应的功能。下面是一些常见事件的例子:

  • 鼠标点击按钮
  • 网页文件在浏览器中加载完成
  • 用户在图片上向右滑动

当用户执行上述任意操作时,他们就“触发”了一个事件。比如,“点击按钮时触发了一个点击事件”。能够响应这些事件的网页,才是有交互性和动态体验的网页。

触发事件

在文档对象模型(DOM)中的某个特定元素上触发特定事件后,可以创建一个**事件处理函数(event handler function)**来作为响应执行操作。在本课中,我们将学习这些事件处理函数是如何在事件触发后修改和更新 DOM 元素的。

我们可以将事件的触发类比为一个更熟悉的场景:一只听到铃声就会开始吃东西的狗!(这被称为巴甫洛夫条件反射。)

如图所示,铃声的响起就像是 JavaScript 中事件的触发。而狗狗被训练去响应铃声,这就好比是在设置一个事件监听器(event listener) 。当狗狗听到铃声后,它就会走过来吃食物!这个反应就类似于事件处理函数的执行,它会运行代码,比如在网页上改变元素的颜色、文本,等等。

大多数浏览器中的事件都会悄悄发生——因为没有绑定事件处理函数,这些事件就不会被“察觉”或响应。而事件处理函数正是使用 JavaScript 创建交互式网站的关键所在。

注册事件处理程序

现在是时候深入了解如何使用事件处理函数来创建交互效果了。

使用 .addEventListener() 方法,我们可以让一个 DOM 元素监听某个特定事件,并在该事件被检测到时执行一段代码。这个监听事件的 DOM 元素被称为事件目标(event target) ,而当事件发生时运行的那段代码被称为事件处理函数(event handler)

我们来看下面这段代码:

let eventTarget = document.getElementById('targetElement');

eventTarget.addEventListener('click', function() {
  // 当 eventTarget 元素上发生点击事件时,这段代码会被执行
});

我们来逐步解析一下这段代码:

  • 我们使用 document.getElementById('targetElement') 从 DOM 中选中了我们的事件目标。
  • 然后在这个 eventTarget 元素上使用了 .addEventListener() 方法。
  • .addEventListener() 方法接受两个参数:一个是表示事件名称的字符串,另一个是事件处理函数。我们将在后面的课程中了解可以使用哪些不同的事件名称。
  • 在这个例子中,我们使用了 'click' 事件,也就是当用户点击 eventTarget 元素时会触发的事件。
  • 当监听到 'click' 事件时,事件处理函数中的代码块就会被执行。

虽然在 .addEventListener() 方法中直接使用**匿名函数(anonymous function)是可以的,但最佳实践是使用具名函数(named function)**来作为事件处理函数。这样你的代码会更加有条理、更易复用,即使代码变得复杂也更容易维护。

来看使用具名函数的语法示例:

function eventHandlerFunction() {
  // 当点击事件发生时,这段代码将被执行
}

eventTarget.addEventListener('click', eventHandlerFunction);

在这个例子中,我们将具名函数 eventHandlerFunction 作为 .addEventListener() 方法的第二个参数传入,而不是在方法内部定义一个匿名函数!

addEventListener('click', eventHandlerFunction)和onclick(eventHandlerFunction)有什么区别

  1. 是否可以绑定多个事件处理函数

onclick 只能绑定一个函数。后绑定的会覆盖掉前面的:

eventTarget.onclick = function () { console.log('第一次'); };
eventTarget.onclick = function () { console.log('第二次'); }; // 覆盖了上面的
// 点击时只会打印「第二次」

addEventListener 可以绑定多个函数,互不影响:

eventTarget.addEventListener('click', function () { console.log('第一次'); });
eventTarget.addEventListener('click', function () { console.log('第二次'); });
// 点击时会依次打印「第一次」「第二次」
  1. 支持的时间类型和高级功能

addEventListener 支持更多功能,比如监听事件捕获阶段(第三个参数),或指定只触发一次({ once: true })等高级配置。

eventTarget.addEventListener('click', handler, { once: true });

onclick 只支持冒泡阶段,不能配置这些高级功能。

  1. 移除事件监听的方式不同

addEventListener 绑定后可以用 removeEventListener 精确移除:

function handleClick() {
  console.log('clicked');
}

eventTarget.addEventListener('click', handleClick);
eventTarget.removeEventListener('click', handleClick);

onclick 想“移除”只能设置为 null 或重新赋值:

eventTarget.onclick = null;

添加事件处理程序

我们已经学习了如何使用 .addEventListener() 方法来注册事件处理程序,但其实还有另一种方法!

事件处理程序也可以通过在 DOM 元素(事件目标)上设置 .onevent 属性的方式来注册。注册特定事件的模式是:给一个元素添加 .on 加上小写的事件类型名称
例如,如果我们想要注册一个点击事件(click event),可以这样写:

eventTarget.onclick = eventHandlerFunction;

在这个例子中,我们给 DOM 元素 eventTarget 设置了 .onclick 属性,并将其值设置为事件处理函数 eventHandlerFunction

需要注意的是:.onevent 属性和 .addEventListener() 方法都可以注册事件监听器。但是:

  • 使用 .onevent(比如 .onclick)的方式只能为一个事件目标附加一个事件处理函数,后添加的会覆盖前一个;
  • 而使用 .addEventListener() 方法,可以附加多个事件处理函数,它们都会被依次调用。

在后续的练习中,我们会主要使用 .addEventListener() 的语法,不过我们也想介绍 .onevent 的用法,因为这两种方式在实际开发中都非常常见。

移除事件处理程序

.removeEventListener() 方法用于撤销 .addEventListener() 方法的效果。这个方法可以让事件目标不再“监听”某个事件的触发,当事件监听不再需要时,就可以使用它。

.removeEventListener() 方法也需要两个参数:

  1. 事件类型(以字符串形式表示)
  2. 要移除的事件处理函数

来看一个使用 .removeEventListener() 方法移除点击事件的语法示例:

eventTarget.removeEventListener('click', eventHandlerFunction);

由于一个特定事件可能绑定了多个事件处理函数,.removeEventListener() 必须提供完全相同的事件类型名称你想移除的事件处理函数的函数名,才能成功移除。

⚠️ 注意:如果你在 .addEventListener() 中使用的是匿名函数,那么这个事件监听器是无法被移除的

事件对象的属性

JavaScript 会将事件保存为 事件对象(Event object) ,其中包含与该事件相关的数据和功能,这些内容以属性和方法的形式存在。

当某个事件被触发时,事件对象可以作为一个 参数 传递给事件处理函数。

function eventHandlerFunction(event){
   console.log(event.timeStamp);
}

eventTarget.addEventListener('click', eventHandlerFunction);

在上面的示例中,当 eventTarget 上触发 'click' 事件时,eventHandlerFunction 函数会接收一个名为 event 的参数。这个参数就是一个 事件对象,**它包含了关于 'click' 事件的相关信息。 ** 接着,我们通过访问事件对象的 .timeStamp 属性,打印出从文档加载完成到该事件被触发所经过的时间(以毫秒为单位)。

事件对象中有一些预定义的属性,你可以调用它们来查看与事件有关的信息,例如:

  • .target 属性:引用事件被注册的那个元素;
  • .type 属性:获取事件的名称(比如 'click');
  • .timeStamp 属性:获取从文档加载完成到事件触发之间经过的毫秒数。

隐藏注册的元素:

event.target.style.display = 'none';

事件类型

除了点击(click)事件之外,浏览器中还有各种各样的 DOM 事件可以被触发!
了解这一点很重要:大多数 DOM 中的事件都会在没有被察觉的情况下发生,这是因为没有为它们绑定事件处理器。

同样也要注意,有些注册的事件不依赖用户的交互也能被触发。
例如,load 事件会在网站的文件完全加载到浏览器之后自动触发。

浏览器还可以在没有用户操作的情况下触发很多其他事件 —— 你可以在 MDN 的 Events Reference 页面 上查看这些事件的列表。

当然,很多事件还是需要用户与 DOM 进行交互后才会触发。
你已经熟悉的一个用户交互事件就是 click 事件 —— 当用户在 DOM 元素上按下并释放鼠标按钮时,click 事件就会被触发。

鼠标事件

当你在网站上使用鼠标设备时,会触发各种鼠标事件。你已经见过 clickwheel 事件,但实际上,还有更多鼠标相关的事件可以探索!

  • mousedown 事件 会在用户按下鼠标按钮时触发。
    这和 click 事件不同,因为 mousedown 不需要等到鼠标按钮释放就会触发。

  • mouseup 事件 会在用户松开鼠标按钮时触发。
    它和 clickmousedown 都不同,因为 mouseup 不依赖鼠标是否按下才能触发。

  • mouseover 事件 会在鼠标进入某个元素的内容区域时触发。

  • mouseout 事件 会在鼠标离开某个元素时触发

function increaseWidth() {
  itemOne.style.width = '401px';
}

itemOne.addEventListener('mouseover', increaseWidth);

function changeBackground(){
  itemTwo.style.backgroundColor = 'blue';
}

itemTwo.addEventListener('mouseup', changeBackground);

function changeText(){
  itemThree.innerHTML = 'The mouse has left the element';
}

itemThree.addEventListener('mouseout', changeText);

function showItem(){
  itemFive.style.display = 'block';
}

itemFour.addEventListener('mousedown', showItem);

键盘事件

除了鼠标事件,键盘事件也是非常常见的一类事件!
键盘事件是由用户在浏览器中与键盘键交互时触发的。

  • keydown 事件 会在用户按下某个键时触发。
    (Key Down 事件图示)

  • keyup 事件 会在用户松开某个键时触发。
    (Key Up 事件图示)

  • keypress 事件 会在用户按下并松开一个键时触发。
    它不同于 keydownkeyup 组合起来的效果,因为 keypress一个完整的事件

键盘事件拥有其独特的事件对象属性,比如 .key 属性,它用于存储用户按下的键的值

你可以在事件处理函数中编程,让它:

  • 某个特定键作出响应,或者
  • 任意键盘操作都作出响应。
let ball = document.getElementById('float-circle');

// Write your code below
function up(){
  ball.style.bottom = '250px';
}

function down(){
  ball.style.bottom = '50px';
}

document.addEventListener('keydown', up);

document.addEventListener('keyup', down);

document 代表整个文档对象模型(DOM)——将 document 作为事件目标,意味着你可以在 DOM 的任何地方触发事件。

你可以使用 .addEventListener() 方法或 .on[event] 属性来为事件类型 keydown 注册你的事件处理函数。

总结

我们来回顾一下你学到的内容:

  • 你可以使用 .addEventListener() 方法将事件注册到 DOM 元素上。
  • .addEventListener() 方法接受两个参数:事件类型 和 事件处理函数。
  • 当事件在事件目标上被触发时,注册的事件处理函数会被执行。
  • 事件处理函数也可以作为事件目标的 .onevent 属性值进行注册。
  • 事件对象的属性(如 .target.type.timeStamp)可以提供关于事件的信息。
  • .addEventListener() 方法可以为同一个事件添加多个事件处理函数。
  • .removeEventListener() 方法可以停止特定的事件处理器监听特定的事件。

数字孪生-DTS-孪创城市-前端实现动态地铁分布线路图

前言

今天蘑菇头带来的是如何用前端实现地铁分布的线路图,可以发现我们不仅标注了地铁的线路分布还做了一些动效,当画面拉进时,还会标注出每个地铁站的位置,快来看看这是怎么实现的吧。

image-20250417152503392.png

image-20250417153202485.png

思路

首先要实现像这样子的效果,我们需要两个数据,一个是地铁的线路数据还有一个是地铁站的位置数据。当然了,这两份数据我们早就准备好了,依然是放在data文件夹下,打开GIS软件可以发现就是一些点和线,然后属性里面会有一个经纬度和站点名称等等信息。

image-20250417153958254.png OK,接下来就是代码的编写了。还是老样子,需要将视角抬高,然后我们先绘制所有的地铁站点位信息,再绘制地铁线路。首先获取到所有的点位,然后通过fdapi.marker.add进行绘制,注意地铁站的icon路径不要填错了。通过调整高度可见范围就可以达到当视角拉近时展示地铁站点位信息,拉远时隐藏。

import { getMetroPoint, getMetroLine } from '@/api/home'

const addMetroMarker = async () => {
  fdapi.camera.set([563205.014687, 3446649.286563, 35655.555, -66.167427, -92.4897, 2])
  const { data } = await getMetroPoint()
  const markerList: any = []

  const map = new Map()
  data.features.forEach((item: any) => {
    const name = item.properties.PointName
    if (!map.has(name)) {
      map.set(name, item)
    }
  })

  const features = [...map.values()]
  features.forEach((item: any, index: number) => {
    const name = item.properties.PointName
    const pointCoord = item.geometry.coordinates

    const imaScale = 0.5
    const marker = {
      id: 'metro_marker_' + index,
      groupId: 'metro_marker',
      coordinate: pointCoord, //坐标位置
      coordinateType: 0, //默认0是投影坐标系,也可以设置为经纬度空间坐标系值为1
      anchors: [(-56 / 2) * imaScale, 72 * imaScale], //锚点,设置Marker的整体偏移,取值规则和imageSize设置的宽高有关,图片的左上角会对准标注点的坐标位置。示例设置规则:x=-imageSize.width/2,y=imageSize.height
      imagePath: '@path:地铁.png', //显示图片路径
      imageSize: [56 * imaScale, 72 * imaScale], //图片的尺寸
      range: [1, 100000], //可视范围
      viewHeightRange: [1, 30000], // 可见高度范围
      fixedSize: true, //图片固定尺寸,取值范围:false 自适应,近大远小,true 固定尺寸,默认值:false

      text: name, //显示的文字
      useTextAnimation: true, //关闭文字展开动画效果 打开会影响效率
      textRange: [1, 20000], //文本可视范围[近裁距离, 远裁距离]
      textOffset: [0, 0], // 文本偏移
      textBackgroundColor: [0, 0, 0, 0], //文本背景颜色
      fontSize: 10, //字体大小
      fontOutlineSize: 2, //字体轮廓线大小
      fontColor: '#d6fed7', //字体颜色
      fontOutlineColor: '#113019', //字体轮廓线颜色

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

    markerList.push(marker)
  })
  fdapi.marker.add(markerList)
}

image-20250417154731982.png

然后就是绘制地铁线路了,和绘制点位信息一样需要先获取到线路数据,通过getMetroLine获取,我们早就封装好了获取方法。然后通过飞渡的polyline进行绘制,一共绘制两组线,一组是底色没有动效的线,一组是有动效的线。

const polylineIds: string[] = []
const addMetroPolyline = async () => {
  const { data } = await getMetroLine()
  console.log(data)

  const polylineList: any = []
  const codeToColor: any = {
    1: '#46ab06',
    2: '#f11b1d',
    3: '#f45302',
    4: '#0b51c6',
    5: '#f52098',
    6: '#1ca2df',
    7: '#9160bb',
    8: '#786e01',
    s1: '#6f3663'
  }
  data.features.forEach((item: any, index: number) => {
    const coordinates = item.geometry.coordinates[0]
    const code = item.properties.code

    polylineIds.push('metro_polyline_' + index)
    const polyline = {
      id: 'metro_polyline_' + index, //折线唯一标识id
      coordinates: coordinates, //构成折线的坐标点数组
      coordinateType: 0, //坐标系类型,取值范围:0为Projection类型,1为WGS84类型,2为火星坐标系(GCJ02),3为百度坐标系(BD09),默认值:0
      range: [1, 1000000], //可视范围:[近裁距离, 远裁距离],取值范围: [任意负值, 任意正值]
      color: codeToColor[code], //折线颜色
      thickness: 160, //折线宽度
      intensity: 1, //亮度
      flowRate: 0.5, //流速
      shape: 0, //折线类型 0:直线, 1:曲线
      depthTest: false, //是否做深度检测 开启后会被地形高度遮挡
      style: 4, //折线样式 参考样式枚举:PolylineStyle
      tiling: 0 //材质贴图平铺比例
    }
    polylineList.push(polyline)

    polylineIds.push('metro_polyline_dynamic_' + index)
    const polyline_dynamic = {
      id: 'metro_polyline_dynamic_' + index, //折线唯一标识id
      coordinates: coordinates.map((c: number[]) => [c[0], c[1], 0.1]), //构成折线的坐标点数组
      coordinateType: 0, //坐标系类型,取值范围:0为Projection类型,1为WGS84类型,2为火星坐标系(GCJ02),3为百度坐标系(BD09),默认值:0
      range: [10000, 10000000], //可视范围:[近裁距离, 远裁距离],取值范围: [任意负值, 任意正值]
      color: codeToColor[code], //折线颜色
      thickness: 200, //折线宽度
      intensity: 100, //亮度
      flowRate: 0.4, //流速
      shape: 0, //折线类型 0:直线, 1:曲线
      depthTest: false, //是否做深度检测 开启后会被地形高度遮挡
      style: 3, //折线样式 参考样式枚举:PolylineStyle
      tiling: 0.5 //材质贴图平铺比例
    }
    polylineList.push(polyline_dynamic)
  })

  fdapi.polyline.add(polylineList)
}

image-20250417153202485.png

最后当退出时需要将绘制的信息删除。

export const exitMetro = async () => {
  fdapi.marker.deleteByGroupId('metro_marker')
  fdapi.polyline.delete(polylineIds)
}

涉及的飞渡api

  • 绘制点-fdapi.marker.add()

  • 绘制线-fdapi.polyline.add()

  • 根据分组ID删除Marker- fdapi.marker.deleteByGroupId('metro_marker')

  • 删除线-fdapi.polyline.delete(polylineIds)

几何遗迹交接文档

一、 组件库

使用了react-konva,因为正方是规则图形所以可以使用Rect组件,但是三角形(尤其等腰直角三角形)并不是规则图形。所以使用Shape组件来通过点来围成图形。

Stage 和 Layer

  • Stage: 这是 react-konva 中的根容器,相当于一个 Canvas 元素。
  • Layer: 这是 Stage 内的容器,用于组织和管理图形元素。一个 Stage 可以包含多个 Layer

Stage 获取当前待拼区域的宽高。

   <Stage width={widthAndHeight?.width} height={widthAndHeight?.height} ref={stageRef}>

1.正方形

正方形涉及到了

  • 是否吸附状态 isAdsorb
  • 是否是半拼图形 isHalf
  • 未吸附的图形提交后需要高亮 isHighlight
  • 没有材质(目标弹窗中是没材质,其他拖拽图形和半拼是有材质的) noMaterial
  • 提交错误摇晃动画 isShake
  • 旋转 旋转动画
  • 是否可以拖拽 draggable
属性 解释
x 正方形的左上角 x坐标
y 正方形的左上角 y坐标
width 正方形的宽
height 正方形的高
dragBoundFunc 正方形拖拽的边界
draggable 是否可以拖拽
stroke 线框颜色
fillPatternImage 填充的图片
fillPatternScale 填充的材质图片适配大小
  <Rect
            ref={rectRef}
            x={position.x}
            y={position.y}
            width={width}
            height={width}
            dragBoundFunc={dragBounds}
            draggable={draggablecopy}
            // 无材质的-也就是目标图形需要边框
            stroke={noMaterial ? 'rgba(216, 124, 60, 1)' : isHighlight ? '#FFFA7B' : ''}
            // strokeWidth={noMaterial ? 4 : 0}
            // onClick={() => rotate90Degrees()} // 点击时旋转
            onTouchStart={handleTouchStart}
            onTouchMove={(e) => {
                // checkCollision()
                if (!isHalf) {
                    clearTimeout(timer.current);
                    gestureRef.current.isDragging = true
                }
            }

            }
            onDragStart={(e) => {
                // 拖拽开始时,将目标图形移到最上层
                e.target.moveToTop();
            }}
            onDragMove={(e) => {
                // if (e.target.attrs.x < 100 || e.target.attrs.y < 40) {
                //     onDragMoveEnd(e, uniqKey)
                //     // setDraggablecopy(false)
                // } else {
                // 吸附的拖拽后就分开
                // test()
                handleSeparate(e, uniqKey)
                // }
            }}

            onDragEnd={(e) => {
                if (gestureRef.current.isDragging == true && !isHalf && !isAdsorb) {
                    // test()
                    moveEnd(e, uniqKey)
                }
                gestureRef.current.isDragging = false

            }}
            onTouchEnd={(e) => {
                // 拖拽结束时触发 避免点击也执行
                if (!isAdsorb) {
                    rotate90Degrees(e)
                }
                // clearTimeout(timer.current);
            }}

            fillPatternImage={noMaterial ? null : imageObj}
            fillPatternRepeat="no-repeat"
            // 让填充的材质图片适配大小
            fillPatternScale={{
                x: width / (imageObj?.naturalWidth || 1),
                y: width / (imageObj?.naturalHeight || 1)
            }}
            rotation={rotation}
            key={uniqKey}
            // 偏移量是中心点的位置
            offset={offset}
        // dragBoundFunc={dragBoundFunc}
        />

1.1 坐标

Rect 组件的坐标是左上角的坐标。涉及到:

  • 为了跟三角形的坐标格式[[],[],[]]统一,也多包了一层[[]],是个二维数组。
  • 计算拼后的图形的长宽时要多加一个shapeSize

1.2 添加材质

正方形添加材质简单,只需要区分一下半拼还是拖住图形。

  useEffect(() => {
        if (!imageObj) {
            const img = new window.Image();
            img.onload = () => {
                setImageObj(img);
            };
            img.src = isHalf ? SqHalfIcon : SqIcon
        }
    }, [imageObj, isHalf]);

1.3 拖拽

  • onTouchStart 用户触碰屏幕,将gestureRef中的isDragging变为true。为了拖拽和旋转不同时发生。
  • onDragStart 当用户开始拖拽元素时触发,拖拽开始将图形移动到最上层。
  • onDragMove 当用户拖拽元素并移动时触发,这里触发了 handleSeparate 用于拖拽将吸附后的图像拆开。
  • onDragEnd 当用户完成拖拽操作时触发,判断当前是否是拖拽状态gestureRef.current.isDragging,并且拖拽结束重置isDragging状态。 拖拽结束

获取到当前位置的坐标

 let newVertices = [e.target.attrs.x, e.target.attrs.y]

检测是否拖拽到弹窗下面,返回原位置

sqUnderModal()

检测是否拖拽到计数下面,返回原位置

sqUnderCounter()

验证是否存在吸附

 let { resPoints, tarRtKey } = adsorb({
     submitShapePoints: otherSubmitShapePoints,
     basicShape,
     currentPoint: newVertices,
     shapeSize
  })

如果是正方形 执行 adsorbSq()

  • 找到原有图形(已经在画布上)的中心点、和当前拖拽图形的中心点。
  • 求当前图形和画布上其他任意图形的距离。
  • 为了区分边吸和点吸,我们要计算两个正方形边吸的中心点距离和点吸的中心点距离。
  • 遍历原有图形的中心点坐标,如果两图形间距小于两正方形的边吸距离,这里还需要判断是往原有图形的上下左右吸附,根据方向再去计算吸附后的位置坐标。
  • 同样如果间距大于边吸距离小于点吸距离 就算为点吸,要判断是往原有图形的左上右上左下右下去吸附。同样计算坐标。
  • 过滤掉当前要吸附的位置上已经存在图形的位置
  • 在过滤完的点中找到离得最近的点。

做吸附后的位置是否超出界限的检测(包括是否吸入到目标弹窗、和计数弹窗)。

如果存在可吸附点,并且满足吸附位置不存在其它图形、没有超出界限。将当前key对应的图形的坐标变为可吸附的坐标,并将吸附状态改为true。最后修改submitShapePoints。

如果不满足吸附条件,需要将isAdsorb变为false.

吸附图形的拆分

onDragMove事件触发时执行,将当前拖拽图形变为此时的位置并且状态变为未吸附。

1.4 旋转

onTouchEnd 事件触发,并且不能存在吸附状态。执行rotate90Degrees方法。

  • timeElapsed 计算下触摸开始到触摸结束的时长。
  • moveDistance 计算下移动距离
  • 满足移动距离<10,触摸时间<200毫秒,执行旋转。
  • new Konva.Tween 旋转动画。
  • 旋转结束后将gestureRef的isDragging状态置为true

2.三角形

2.1坐标

三个坐标点组成的二维数组。按照顺时针或者逆时针连线。变量pointsArrayWithRotate用于记录点的位置并且是旋转之后的。通过drawShape来进行绘制。

2.2添加材质

坐标点、角度改变就要替换新的图片材质。

  • fillImage 图片地址,首先判断是否有材质、是等边三角形(rt)还是等腰直角三角形(et)。然后判断角度替换不同的图片材质,在初始角度中还要判断是否是半拼图形。
  • resizeImage 将图添加到画布上,然后重新给画布尺寸,再转为图片
  • 通过shapeRef shapeNode.fillPatternImage(imageObj) 填充图案。居中填充图案 shapeNode.fillPatternOffset({ x: -left, y: -top, });。否则会出现填充图repeat。
currentRtDir() 判断三角形冲上还是冲下
 // 两个y相等 并且y单独一样的点在另两个y相同的点的下面  三角形冲下
 // 两个y相等 并且y单独一样的点在另两个y相同的点的上面   三角形冲上
// 等边三角形:每旋转60度 就换个图
// 直角三角形:没旋转9度  换图 还需要判断左右朝向
currentLeftDir() 
两个x值相等的点 的x值大于单个点的x值 向右
反之 向左

2.3 图形旋转

旋转 围绕图形的中心点旋转 重新计算坐标点。onTouchEnd事件结束后

rotateTriangle来计算三角形旋转后的坐标。将坐标点替换。

getRtCenter()  获取直角三角形的中心点
getReCenter()  获取等边三角形中心点 

2.4 添加错误摇晃动画

变量 isShake 2秒结束后停止动画 并且 重置位置

new Konva.Animation()  
//2秒结束后停止动画 并且 重置位置
animRef.current.stop();
setPosition({ x: 0, y: 0 }); 

2.5 拖拽

moveEnd

  • 计算新的坐标点
  • 通用进行 underModal underCounter 检测。
  • 看是否存在与当前坐标点吸附的点(要过滤掉自身)
  • 这里三角形执行adsorbEt(之前区分了等边和直角三角形,但好像代码一样)
  • 遍历 submitShapePoints 得到pointWidthDis 计算的是当前三角形的三个点和目标点的距离 计算了
 * @targetPoint 目标要吸附的点
 * @curPoint 当前拖拽的三角形离目标最近的点
 * @curRt 当前拖拽的三角形的三个点
 * @distance 两点间距离
 * @adsorbRtPoint 要吸附的三角形的点
  • pointWithAdsorbResult 遍历出吸附后的三角形位置

二、目标图形

  • 根据给的属性去生成目标图形的点。
  • 有内部边线 和 无内部边线(简称无边线)两种。 无边线的题目答错后给出边线
  • 展开收起后 目标图形的适配

1.数据

属性 描述
basic_shape 基本图形 sq(正方形) 、rt(等边三角形)、 et(直角三角形)
target_shape 目标图形 sq(正方形)、re(长方形) 、rt(等边三角形)、 et(直角三角形)、tr(梯形)、he(六边形)、pa(平行四边形)
half_shape_position 半拼图形所在位置 如 "1,2,3,4"
question_hard_type 题型 (1是有辅助线,2是无辅助线,3是有网格)
operate_count 可操作数量
target_shape_count 目标图形数量
target_specific_value 目标图形的特殊值(长方形的长宽、梯形的宽高、)
shapseSize 基本图形的边长 默认 pxToVhGeo

2.根据数据生成图形

根据 size, targetShape, basicShape, targetShapeCount, shapeSize, showLine, targetSpecificValue 以上属性每次变化重新生成点。

  • getSmallModalSize 计算收起状态下的shapseSize是多少

    如下未展开之前取小图形的shapeSize 展开后以 160为基准

 const shapeSize = useMemo(() => {
        return expanded ? pxToVhGeo(160) : samllModalShapeSize
    }, [expanded, samllModalShapeSize])
  • shapeSize进行了取整处理(解决0.几的误差造成的图片缝隙)

image.png

  • 符合 specialEt条件和 tr rt he 几种图形的原点origin的x值为图形的中点,值为2 是为了不让图形贴到画布的边缘 ,否则原点为[1,1]也是为了不贴到画布边缘

2.1 计算小图形的shapeSize

根据画布的宽高来计算每一小块的shapeSize

const getSmallModalSize = ({ funcName, canvasWidth, targetShapeCount, targetSpecificValue }) => {
const data = configModalSize[funcName].handler({
canvasWidth,
targetShapeCount,
targetSpecificValue,
});
return data;
};
const configModalSize = {
// 正方形正方形 块数取根号就能知道每行多少块
'sqsq': {
handler: ({ canvasWidth, targetShapeCount }) => {
let row = Math.sqrt(targetShapeCount);
return canvasWidth / row;
},
},
// 正方形——长方形  targetSpecificValue 是长宽 画布除以行或列中最多的
'sqre': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {
let row = targetSpecificValue.split(',')[0];
let height = targetSpecificValue.split(',')[1];
let max = row > height ? row : height;
return canvasWidth / max;
},
},
// 直角三角形平行四边形 画一个平行四边形 发现平行四边形外围的长方形的长事 2个直角边长
'etpa': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {
let [side, height] = targetSpecificValue.split(',');

return canvasWidth / (Number(side) + Number(height));
},
},
// 等腰直角三角形-等腰直角三角形 有两种情况 如下2.1图一
'etet': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {                  // 根据targetSpecificValue值来判断是哪种情况
let [w, h] = targetSpecificValue.split(',');
                        // 这里应该用不上这么求了 因为给了高
                        let height = reduceToZero(targetShapeCount);
                        // 底边和高相等的
                        return canvasWidth / height;
} else {
                          // 底边和高不等的
let max = w > h ? w : h;
return canvasWidth / max;
}
},
},
// 等腰直角三角形-长方形
'etre': {
handler: ({ canvasWidth, targetShapeCount, shapeSize, targetSpecificValue }) => {
let [column, row] = targetSpecificValue.split(',');
let max = column > row ? column : row;
return canvasWidth / max;
},
},
'etsq': {
handler: ({ canvasWidth, targetShapeCount, shapeSize, targetSpecificValue }) => {
// 两块拼出一个正方形
let side = Math.sqrt(targetShapeCount / 2);
return canvasWidth / side;
},
},
'ettr': {
handler: ({ canvasWidth, targetShapeCount, shapeSize, targetSpecificValue }) => {
// 8n - 4
let row = (targetShapeCount + 4) / 8;
// 2n+1
let countRow = 2 * row + 1;
// 4 8 12
return canvasWidth / countRow;
},
},
// 正三角形——六边形
'rthe': {
handler: ({ canvasWidth, targetShapeCount, shapeSize, targetSpecificValue }) => {
let height = Math.sqrt(shapeSize ** 2 - (0.5 * shapeSize) ** 2);
return canvasWidth / 3;
},
},
// 正三角形平行四边形
'rtpa': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {
let [side, height] = targetSpecificValue.split(',');
//  依次错开1/2 1 3/2 2
return Number(canvasWidth) / (Number(side) + Number((1 / 2) * Number(height)));
},
},
'rtrt': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {
// 共几行
let row = Math.sqrt(targetShapeCount);
return canvasWidth / row;
},
},
'rttr': {
handler: ({ canvasWidth, targetShapeCount, targetSpecificValue }) => {
let [endRow, column] = targetSpecificValue.split(',');
let endRowCount = 2 * endRow - 1;
// 共几行
// let row = reduceTr(targetShapeCount);
// let lastWidth = row + 1;
return canvasWidth / endRow;
},
},
'': {
handler: () => {
return 0;
},
},
};

2.1图image.png

2.2 生成目标图形

generateTargetPoints() 内部调用了 getTargetPoints()

const getTargetPoints = ({
funcName,
targetShapeCount,
shapeSize,
origin,
targetSpecificValue,
}) => {
// 后端给的是 宽高 但是这里遍历是行列
// const [column, row] = targetSpecificValue.split(',');
const data = config[funcName].handler({
origin,
targetShapeCount,
shapeSize,
targetSpecificValue,
});
const groupedPoints: any = [];
data.map((item) => {
groupedPoints.push(groupByTwo(item));
});
let res = groupedPoints.map((item) => ({
...basicPointParam,
points: item,
key: uuid(),
}));
return res;
};
2.2.1 sqsq 正方形-正方形

正方形使用的是Rect组件,坐标点是左上角的点。两层循环 向右向下平移shapeSize。得出坐标点

handler: ({ origin, targetShapeCount, shapeSize }) => {
let row = Math.sqrt(targetShapeCount);
let triangles: any = [];
for (let i = 0; i < row; i++) {
for (let j = 0; j < row; j++) {
triangles.push([origin[0] + j * shapeSize, origin[1] + i * shapeSize]);
}
}
return triangles;
},
2.2.2 sqre 正方形长方形

与上一个类似

handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let triangles: any = [];
// 后端给的是长宽 这里 长对应列 宽对应行
let [column, row] = targetSpecificValue.split(',');
for (let i = 1; i <= row; i++) {
for (let j = 1; j <= column; j++) {
triangles.push([
origin[0] + (j - 1) * shapeSize,
origin[1] + (i - 1) * shapeSize,
]);
}
}
return triangles;
},
2.2.3 etpa 直角三角形平行四边形

这里给的targetSpecificValue是平行四边形的底边长和高。 剩下的其他图形大部分都是找规律

  • 底边长*2 对应的是有几列
  • 高 对应的是有几行
  • 按规律进行平移
// 直角三角形平行四边形
'etpa': {
handler: ({ origin = [0, 0], targetShapeCount, shapeSize, targetSpecificValue }) => {
let [column, row] = targetSpecificValue.split(',');
// 这里给的是边长 和
column = column * 2;
let triangles: any = [];
// 遍历行
for (let i = 1; i <= row; i++) {
let moveY = (i - 1) * shapeSize;
// 遍历列
for (let j = 1; j <= column; j++) {
if (j % 2 === 1) {
// 1 3 5 移动了 0 1 2所以向下取整,每行依次错位 0 1 2个shapeSize
let moveX = Math.floor(j / 2) * shapeSize + (i - 1) * shapeSize;
// 点的顺序 上右下
triangles.push([
origin[0] + moveX,
origin[1] + moveY,
origin[0] + shapeSize + moveX,
origin[1] + moveY,
origin[0] + shapeSize + moveX,
origin[1] + shapeSize + moveY,
]);
} else {
// 2 4 6 移动了 0 1 2
let moveX = (j / 2) * shapeSize + (i - 2) * shapeSize;
triangles.push([
origin[0] + shapeSize + moveX,
origin[1] + moveY,
origin[0] + shapeSize + moveX,
origin[1] + shapeSize + moveY,
origin[0] + 2 * shapeSize + moveX,
origin[1] + shapeSize + moveY,
]);
}
}
}
return triangles;
},
},
2.2.4 etet 直角三角形直角三角形
  • 分为两种底和高相等的 底和高不等的
  • 底和高相等的 第一行1块,第二行3块,第三行5块。具体规律在注释中
  • 底和高不相等 没找出太多规律 所以 维护了个朝向的数组leftPointIndex
'etet': {
handler: ({ origin = [0, 0], targetShapeCount, shapeSize, targetSpecificValue }) => {
let [column, row] = targetSpecificValue.split(',');
let side = reduceToZero(targetShapeCount);
let triangles: any = [];
const leftBeginMap = new Map([
[1, 1],
[2, 3],
[3, 9],
[4, 19],
]);
const leftEndMap = new Map([
[1, 1],
[2, 5],
[3, 13],
[4, 25],
]);
const rightEndMap = new Map([
[1, 2],
[2, 8],
[3, 18],
[4, 32],
]);
const rightBeginMap = new Map([
[1, 2],
[2, 6],
[3, 14],
[4, 26],
]);
if (row === column) {
for (let i = 1; i <= side; i++) {
// 每行有几块
let count = 2 * i - 1;

for (let j = 1; j <= count; j++) {
let arr: any = [];
// 向上取整   第二行第一块移动0 第二行第二块移动0 第二行第三块移动1
let x_move = (Math.ceil(j / 2) - 1) * shapeSize;
// 每行都向下移动 i-1
let y_move = (i - 1) * shapeSize;
// 奇数 偶数  的三角形朝向不一样 点的生成 有一点区别
if (j % 2 === 1) {
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize
);
} else if (j % 2 === 0) {
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move
);
}
triangles.push(arr);
}
}
} else {

for (let i = 1; i <= row; i++) {
let count = (2 * i - 1) * 2;

let beginVal = leftBeginMap.get(i);
for (let j = beginVal; j <= beginVal + count - 1; j++) {
let endVal = leftEndMap.get(i);

if (leftPointIndex.indexOf(j) > -1) {
let arr: any = [];
let y_move = (i - 1) * shapeSize;
if (j % 2 === 1) {
let x_move = ((j - endVal) / 2) * shapeSize;
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move,
origin[1] + y_move + shapeSize,
origin[0] + x_move - shapeSize,
origin[1] + y_move + shapeSize
);
} else if (j % 2 === 0) {
let x_move = ((j - endVal + 1) / 2) * shapeSize;
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move - shapeSize,
origin[1] + y_move + shapeSize,
origin[0] + x_move - shapeSize,
origin[1] + y_move
);
}
triangles.push(arr);
} else {
// let x_move = (Math.ceil(j / 2) - 1) * shapeSize;
let y_move = (i - 1) * shapeSize;
let arr = [];
if (j % 2 === 1) {
let x_move = ((j - endVal) / 2 - 1) * shapeSize;
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move
);
} else if (j % 2 === 0) {
let x_move = ((j - endVal + 1) / 2 - 1) * shapeSize;
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize
);
}
triangles.push(arr);
}
}
}
}
return triangles;
},
},

image.png

2.2.5 etre 直角三角形矩形
  • targetSpecificValue 是长方形的长宽。长对应列、宽对应行。
  • 同样因为是直角三角形所以 生成的点的朝向需要通过 奇偶来区分
// 直角三角形矩形  row, column 行列
'etre': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let [column, row] = targetSpecificValue.split(',');
let triangles: any = [];
for (let i = 1; i <= row; i++) {
let count = column * 2;
for (let j = 1; j <= count; j++) {
let arr: any = [];
let x_move = (Math.ceil(j / 2) - 1) * shapeSize;
let y_move = (i - 1) * shapeSize;
if (j % 2 === 1) {
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize
);
} else if (j % 2 === 0) {
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move
);
}

triangles.push(arr);
}
}
return triangles;
},
},
2.2.6 etsq 直角三角形正方形
  • 与矩形的类似 简单一些
'etsq': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let triangles: any = [];
let row = Math.sqrt(targetShapeCount / 2);
for (let i = 1; i <= row; i++) {
let count = row * 2;
for (let j = 1; j <= count; j++) {
let arr: any = [];
let x_move = (Math.ceil(j / 2) - 1) * shapeSize;
let y_move = (i - 1) * shapeSize;
if (j % 2 === 1) {
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize
);
} else if (j % 2 === 0) {
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move + shapeSize,
origin[1] + y_move + shapeSize,
origin[0] + x_move + shapeSize,
origin[1] + y_move
);
}

triangles.push(arr);
}
}
return triangles;
},
},
2.2.7 ettr 直角三角形梯形
  • 最开始没给targetSpecificValue,所以求了一下 rows 规律是: 8n - 4
  • 求得梯形中点 从中点向两边画图形。
// 直角三角形梯形
'ettr': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let triangles: any = [];
// 8n - 4
let rows = (targetShapeCount + 4) / 8;
// 2n+1
// 梯形求得中点
let halfPoint = ((2 * rows + 1) * shapeSize) / 2;
// 如果不传自动计算
origin = origin[1] > 0 ? origin : [halfPoint, 0];
for (let i = 1; i <= rows; i++) {
let count = 4 * i;
for (let j = 1; j <= count; j++) {
let arr: any = [];
let half = count / 2;
if (j === 1) {
let move_x = (i - 1) * shapeSize;
let move_y = (i - 1) * shapeSize;
arr.push(
origin[0] - 0.5 * shapeSize - move_x,
origin[1] + move_y,
origin[0] - 1.5 * shapeSize - move_x,
origin[1] + shapeSize + move_y,
origin[0] - 0.5 * shapeSize - move_x,
origin[1] + shapeSize + move_y
);
} else if (j % 2 === 0) {
// 左移 1.5 左移0.5 右移0.5 右移1.5
let move_x = 0.5 * shapeSize * (j - 1 - half);
let move_y = (i - 1) * shapeSize;
arr.push(
origin[0] + move_x,
origin[1] + move_y,
origin[0] + move_x,
origin[1] + shapeSize + move_y,
origin[0] + move_x + shapeSize,
origin[1] + shapeSize + move_y
);
} else {
let move_x = 0.5 * shapeSize * (j - 2 - half);
let move_y = (i - 1) * shapeSize;
arr.push(
origin[0] + move_x,
origin[1] + move_y,
origin[0] + move_x + shapeSize,
origin[1] + move_y,
origin[0] + move_x + shapeSize,
origin[1] + shapeSize + move_y
);
}
triangles.push(arr);
}
}
return triangles;
},
},
2.2.8 rthe 等边三角形-六边形
  • 六边形只有六块拼的题型 所以没有太大众的生成规律,应该可以不用动
// 六边形
'rthe': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let height = Math.sqrt(shapeSize ** 2 - (shapeSize / 2) ** 2);
// 因为目标弹窗中六边形有锯齿 所以 往右移动了一些 涉及到 errorX errorY(如果origin[1] >10 则为0 相乘也是0)
let errorX = 0;
let errorY = 0;
if (origin[1] > 0) {
if (origin[1] > 10) {
} else {
origin = [(shapeSize * 2) / 2, origin[1]];
errorX = 1;
errorY = 1;
}
} else {
origin = [(shapeSize * 2) / 2, 0];
}
let triangles: any = [];
let rows = 2;
for (let i = 1; i <= rows; i++) {
let count = targetShapeCount / 2;
for (let j = 1; j <= count; j++) {
let arr: number[] = [];
let x_move = (Math.ceil(j / 2) - 1) * shapeSize;
let y_move = (i - 1) * height;
if (i % 2 === 1) {
// 这也是为了解决目标弹窗锯齿
let valY = -1;
let valX = j === 3 ? 2 : -0.5;

if (j % 2 === 1) {
// x+len y+len
arr.push(
origin[0] + x_move + valX * errorX,
origin[1] + y_move + valY,
origin[0] + x_move - shapeSize / 2 + valX * errorX,
origin[1] + y_move + height + valY,
origin[0] + x_move + shapeSize / 2,
origin[1] + y_move + height + valY
);
} else if (j % 2 === 0) {
arr.push(
origin[0] + x_move + errorY,
origin[1] + y_move + valY,
origin[0] + x_move + shapeSize / 2 + errorY,
origin[1] + y_move + height + valY,
origin[0] + x_move + shapeSize + errorY,
origin[1] + y_move + valY
);
}
} else {
if (j % 2 === 1) {
//  下部分顶点变了 其余两个点去掉移动
let val = j === 3 ? 1 : -0.5;
arr.push(
origin[0] + x_move + val * errorX,
origin[1] + height + y_move + errorY,
origin[0] + x_move - shapeSize / 2 + val * errorX,
origin[1] + height + errorY,
origin[0] + x_move + shapeSize / 2 + val * errorX,
origin[1] + height + errorY
);
} else if (j % 2 === 0) {
// 跟 i % 2 === 1 & j % 2 === 1 的区别
arr.push(
origin[0] + x_move + shapeSize / 2 + errorX,
origin[1] + y_move + 1.5 * errorY,
origin[0] + x_move - shapeSize / 2 + shapeSize / 2 + errorX,
origin[1] + y_move + height + 1.5 * errorY,
origin[0] + x_move + shapeSize / 2 + shapeSize / 2 + errorX,
origin[1] + y_move + height + 1.5 * errorY
);
}
}
let roundArr = arr.map((num) => Math.round(num));
triangles.push(roundArr);
}
}

return triangles;
},
},
2.2.9 rtpa 正三角形平行四边形
  • 平行四边形的 底边对应列、高对应行
  • 这里等边三角形的高是勾股定理求完后的
// 正三角形平行四边形
'rtpa': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let [column, row] = targetSpecificValue.split(',');
let triangles: any = [];
for (let i = 0; i < row; i++) {
// 一列的
let count = column * 2;
for (let j = 0; j < count; j++) {
let arr: any = [];
let x_move = (i / 2) * shapeSize + (j / 2) * shapeSize;
// let x_move = (Math.ceil(j / 2) - 1) * shapeSize - (shapeSize * i) / 2;
let height = Math.sqrt(shapeSize ** 2 - (shapeSize / 2) ** 2);
let y_move = i * height;
if (j % 2 === 0) {
// x+len y+len
arr.push(
origin[0] + x_move,
origin[1] + y_move,
origin[0] + x_move + shapeSize / 2,
origin[1] + height + y_move,
origin[0] + shapeSize + x_move,
origin[1] + y_move
);
} else if (j % 2 === 1) {
let x_move = (i / 2) * shapeSize + ((j - 1) / 2) * shapeSize;
arr.push(
origin[0] + x_move + shapeSize / 2,
origin[1] + height + y_move,
origin[0] + shapeSize + x_move,
origin[1] + y_move,
origin[0] + (3 / 2) * shapeSize + x_move,
origin[1] + height + y_move
);
}
triangles.push(arr);
}
}
return triangles;
},
},
2.2.10 rtrt正三角形正三角形
  • 这里因为之前后端数据不给targetSpecificValue,所以通过 Math.sqrt(targetShapeCount)计算有几行
  • 原点,如果不是[0,0]就取传进来的原点,否则[最后一行的宽/2,0]
// 正三角形正三角形
'rtrt': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let row = Math.sqrt(targetShapeCount);
let lastRowCount = 2 * row - 1;
let height = Math.sqrt(shapeSize ** 2 - (0.5 * shapeSize) ** 2);
origin = origin[0] > 0 ? origin : [(lastRowCount * shapeSize) / 2, 0];
let triangles: any = [];
let rows = reduceToZero(targetShapeCount);
for (let i = 1; i <= rows; i++) {
let count = 2 * i - 1;
for (let j = 1; j <= count; j++) {
let arr: any = [];
let height = (Math.sqrt(3) / 2) * shapeSize;
let move_x = (shapeSize / 2) * (j - 1) - (shapeSize / 2) * (i - 1);
let move_y = (i - 1) * height;
if (j % 2 === 1) {
arr.push(
origin[0] + move_x,
origin[1] + move_y,
origin[0] - shapeSize / 2 + move_x,
origin[1] + height + move_y,
origin[0] + shapeSize / 2 + move_x,
origin[1] + height + move_y
);
} else {
let move_h = i * height;
arr.push(
origin[0] - shapeSize / 2 + move_x,
origin[1] + move_y,
origin[0] + shapeSize / 2 + move_x,
origin[1] + move_y,
origin[0] + move_x,
origin[1] + move_h
);
}
triangles.push(arr);
}
}
return triangles;
},
},
2.2.11 rttr正三角形梯形
// 正三角形梯形
'rttr': {
handler: ({ origin, targetShapeCount, shapeSize, targetSpecificValue }) => {
let [endRow, column] = targetSpecificValue.split(',');
let beginRow = endRow - column + 1;
let triangles: any = [];
// let rows = reduceTr(targetShapeCount);
origin = origin[1] > 0 ? origin : [(endRow * shapeSize) / 2, 0];

for (let i = beginRow; i <= endRow; i++) {
let count = 2 * i - 1;
for (let j = 1; j <= count; j++) {
let arr: any = [];
let height = (Math.sqrt(3) / 2) * shapeSize;
let move_x = (shapeSize / 2) * (j - 1) - (shapeSize / 2) * (i - 1);
// let move_y = (i - 1) * height;
let move_y = (i - beginRow) * height;
if (j % 2 === 1) {
arr.push(
origin[0] + move_x,
origin[1] + move_y,
origin[0] - shapeSize / 2 + move_x,
origin[1] + height + move_y,
origin[0] + shapeSize / 2 + move_x,
origin[1] + height + move_y
);
} else {
// let move_h = i * height;
arr.push(
origin[0] - shapeSize / 2 + move_x,
origin[1] + move_y,
origin[0] + shapeSize / 2 + move_x,
origin[1] + move_y,
origin[0] + move_x,
origin[1] + height + move_y
);
}
triangles.push(arr);
}
}
return triangles;
},
},

2. 边界处理

三、 生成半拼图形

generateHalfShape

  • 首先要生成好答案 原点[0,0]
  • 求生成的答案居中后的原点
  • 根据中心点再去生成新的坐标点。
  • 根据提供的半成品位置 过滤出半成品的坐标点。

四 、验证算法

上面说过正方形组件Rect、和三角形组件Shape的点不一样。正方形用的是左上角的坐标,三角形用到的是三个点的坐标。

正方形

验证规则:

四条边相等(但是因为或多或少存在误差 改为四条边的相差在10以内)。

为了校验存在空心的情况,还校验了面积是否跟答案相同。

1. sq-sq

要通过basicIsSq获取坐标点。

  • 要按照顺时针找点
  • 因为正方形记录的是左上角的点所以最右的点的x坐标要+shapeSize,最下边的点的y坐标要+shapeSize

2.et-sq

  • 找到 左上、右上、左下、右下四个点 ,在进行个去重
  • 同上

长方形

验证规则:

对边相等(只要两两相等),每个角都是90°(区分平行四边形)。并且还需要和正方形区分。

1. sq-re

  • 通过basicIsSq获取坐标点
  • 将边长从小到大排序,最小和最大的不相等(误差不小于10)才是长方形
  • 最大的两条边、最短的两条边都相等(相减小于误差)
  • 顺时针三点连线出两条边,计算出四个角,四个角都相等(误差不小于10)
  • 与答案面积比较

2. et-re

  • 找到 左上、右上、左下、右下四个点 ,在进行个去重
  • 同上

等边三角形(正三角形)

验证规则:

三边相等

  • 找到最上、最下、最左、最右的点,去重
  • 计算出相邻点的距离
  • 计算了面积-海伦公式 比较面积取根号的误差是否小于3
  • 计算三条边是否相等

直角三角形

验证规则:

符合勾股定理

  • 找到 上下的一堆点,然后从上的点中找到左上右上、从下的一堆点中找到左下和右下。最后找到最左和最右的点(特殊的直角三角形)
  • 依次左上 右上 左下 右下 左 右 去重
  • 去重后如果不是三个点 还需要再处理一下
  • 点点连线成边
  • 面积(海伦公式)取根号比较
  • 勾股定理

平行四边形

验证规则:

对边相等,对角相等、每个角都不为九十度。

  • 这里平行四边形因为朝向不同 所以找到的点有可能有问题。
  • 按正常的方式找到的点如果不满足
  • 就找上 右(多个) 下 左(多个)再去重

image.png

  • 计算面积

如果是特殊的形状 ,使用公式 边长和夹角法。 否则 底乘高

梯形

验证规则:

角两两相等、边长存在至少两条相等、并且不两两相等。面积

  • 三种不同角度的梯形 所以有三套找点的方式。
  • 面积还是使用海伦公式

六边形

6条边相等

10.vue3中组件实现原理(上)

1.组件的概念

一句话总结:组件就是一组DOM元素的封装, 这组DOM元素就是组件要渲染的内容,如下面代码

 // MyComponent 是一个组件,它的值是一个选项对象
 const MyComponent = {
   name: 'MyComponent',
   data() {
   return { foo: 1 }
   }
 }

如果从渲染器中来看,一个组件则是一个特殊类型的虚拟DOM节点,那我们对于文本,字符串都可以用string,Text等类型来描述,那么对于组件来说,我们一样可以用虚拟节点的vnode.type属性来存储组件的选项对象,例如:

 // 该 vnode 用来描述组件,type 属性存储组件的选项对象
 const vnode = {
   type: MyComponent
   // ...
 }

为了让渲染器可以处理组件类型的虚拟节点,我们就可以再patch函数中对组件类型的虚拟节点进行处理,简单的示例:

 function patch(n1, n2, container, anchor) {
 const { type } = n2
   if (typeof type === 'string') {
   // 作为普通元素处理
   } else if (type === Text) {
   // 作为文本节点处理
   } else if (type === Fragment) {
     // 作为片段处理
   } else if (typeof type === 'object') {
     // vnode.type 的值是选项对象,作为组件来处理
     if (!n1) {
       // 挂载组件
     mountComponent(n2, container, anchor)
     } else {
       // 更新组件2 patchComponent(n1, n2, anchor)
    }
 }

我们通过代码就可以很直观的理解到,组件就好像跟文本,元素使一样的,我们把组件当成一个对象来处理,但是对象中又包含文本和元素等节点,我们就用mountComponentpatchComponent来进行组件的更新和挂载。因此,一个组件必须包含一个渲染函数,那就是我们说的render函数,并且渲染函数返回值应该是虚拟DOM。换句话说,组件渲染函数就是用来描述组件所渲染内容的接口,如下代码:

 const MyComponent = {
 // 组件名称,可选
   name: 'MyComponent',
   // 组件的渲染函数,其返回值必须为虚拟 DOM
   render() {
   // 返回虚拟 DOM
   return {
     type: 'div',
     children: `我是文本内容`
     }
   }
 }

这就是最简单的组件示例,那有了组件的结构后,渲染器就可以完成组件的渲染了,最终其实也是调用的就是mountComponent函数来进行渲染

  const CompVNode = {
     type: MyComponent
  }
 // 调用渲染器来渲染组件
 renderer.render(CompVNode, document.querySelector('#app'))

function mountComponent(vnode, container, anchor) {
   // 通过 vnode 获取组件的选项对象,即 vnode.type
   const componentOptions = vnode.type
   // 获取组件的渲染函数 render
   const { render } = componentOptions
   // 执行渲染函数,获取组件要渲染的内容,即 render 函数返回的虚拟 DOM
   const subTree = render() // 最后调用 patch 函数来挂载组件所描述的内容,即 subTree
   patch(null, subTree, container, anchor)
 }

2.组件的状态和自更新

那我们有了组件,那每个组件里面的data,包括render函数中渲染的变量,都是有状态的,当我们data实现了变化的时候,那我们组件也应该对应的更新,那我们就想,怎么样,才能让组件实现更新,其实很简单,我们只要将渲染任务包装到effect中,实现响应收集就好了,思路如下:

我们以简单的组件为例子:

 const MyComponent = {
   name: 'MyComponent',
   // 用 data 函数来定义组件自身的状态
   data() {
     return {
       foo: 'hello world'
     }
   },
   render() {
     return {
       type: 'div',
       children: `foo 的值是: ${this.foo}` // 在渲染函数内使用组件状态
     }
   }
 }

那我们就是要再mountComponent函数中,实现以来收集

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data } = componentOptions
   const state = reactive(data())
   // 将组件的 render 函数调用包装到 effect 内
   effect(() => {
     const subTree = render.call(state, state)
     patch(null, subTree, container, anchor)
     })
 }

这样,一旦组件的数据进行了变化,那组件就会完成更新,那这样就没有问题了嘛?我们知道effect的执行是同步的,当我们数据多次变化的时候,难道我们要多次执行渲染更新吗,这样是不是会造成性能浪费?那我们就想到,需要设计一个机制,就是响应式数据不管变化多少次,我们的副作用函数就执行一次,这就是我们常说的Vue的异步更新机制, 其实实现思路很简单,我们需要实现我们之前说的实现一个调度器,我们将它缓冲到一个微任务队列中,等到 缓冲到一个微任务队列中,等到执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。具体实现如下:执行栈清空后,再将它从微任务队列中取出并执行。有了缓存机制,我们就有机会对任务进行去重,从而避免多次执行副作用函数带来的性能开销。具体实现如下:

 // 任务缓存队列,用一个 Set 数据结构来表示,这样就可以自动对任务进行去重
 const queue = new Set()
   // 一个标志,代表是否正在刷新任务队列
   let isFlushing = false
   // 创建一个立即 resolve 的 Promise 实例
   const p = Promise.resolve()
  
   // 调度器的主要函数,用来将一个任务添加到缓冲队列中,并开始刷新队列
   function queueJob(job) {
     // 将 job 添加到任务队列 queue 中
     queue.add(job)
     // 如果还没有开始刷新队列,则刷新之
     if (!isFlushing) {
     // 将该标志设置为 true 以避免重复刷新
     isFlushing = true
     // 在微任务中刷新缓冲队列
     p.then(() => {
     try {
     // 执行任务队列中的任务
       queue.forEach(job => job())
     } finally {
     // 重置状态
       isFlushing = false
       queue.clear = 0
     }
   })
  }
 }

本质上就是利用了微任务的异步执行机制, 那我们改写一下mountComponent函数的实现:

 function mountComponent(vnode, container, anchor) {
  ---------
     effect(() => {
       const subTree = render.call(state, state)
       patch(null, subTree, container, anchor)
     }, {
       // 指定该副作用函数的调度器为 queueJob 即可
       scheduler: queueJob
     })
 }

3.组件的实例和生命周期

组件的实例本质上就是一个对象,它维护着组件运行过程中的所有信息,例如组件的生命周期,组件的渲染子树组件是否被挂载组件自身的状态(data),那我们就需要一个对象来描述上述的信息,简单示例如下:

 function mountComponent(vnode, container, anchor) 
 // 定义组件实例,一个组件实例本质上就是一个对象,它包含与组件有关的状态
   const instance = {
   // 组件自身的状态数据,即 data
     state,
     // 一个布尔值,用来表示组件是否已经被挂载,初始值为 false
     isMounted: false,
     // 组件所渲染的内容,即子树(subTree)
     subTree:null
   }
    vnode.component = instance
}

有了组件的实例,我们就知道组件状态,是否被挂载等信息,那我们就可以根据状态去判断组件是挂载还是更新,如下示例:

 function mountComponent(vnode, container, anchor) 
   ----- ----
  effect(() => {
     const subTree = render.call(state, state)
     if (!instance.isMounted) {  //判断是否挂在,没有挂载则挂载
       patch(null, subTree, container, anchor)
       instance.isMounted = true
     } else { //要不然就更新子树
       patch(instance.subTree, subTree, container, anchor)
     }
     instance.subTree = subTree
     }, { scheduler: queueJob })
}

那我们知道了组件的挂载和更新逻辑,那对于组件的生命周期钩子实现不就是很简单了嘛,我们只需要再对应的时候,加上回调函数,就实现了生命周期的钩子,完整代码:

生命周期钩子的实现

 function mountComponent(vnode, container, anchor) {
   const componentOptions = vnode.type
   const { render, data, beforeCreate, created, beforeMount,
  mounted, beforeUpdate, updated } = componentOptions
  
   // 在这里调用 beforeCreate 钩子
   beforeCreate && beforeCreate()
   const state = reactive(data())
   const instance = {
     state,
     isMounted: false,
     subTree: null
   }
   vnode.component = instance
  
   // 在这里调用 created 钩子
   created && created.call(state)
  
   effect(() => {
     const subTree = render.call(state, state)
     if (!instance.isMounted) {
       // 在这里调用 beforeMount 钩子
       beforeMount && beforeMount.call(state)
       patch(null, subTree, container, anchor)
       instance.isMounted = true
       // 在这里调用 mounted 钩子
       mounted && mounted.call(state)
     } else {
       // 在这里调用 beforeUpdate 钩子
       beforeUpdate && beforeUpdate.call(state)
       patch(instance.subTree, subTree, container, anchor)
       // 在这里调用 updated 钩子
       updated && updated.call(state)
     }
       instance.subTree = subTree
   }, 
     { scheduler: queueJob })
 }

总结:

我们首先从组件的选项对象中取得注册到组件上的生命周期函数,然后在合适的时机调用它们,这其实就是组件生命周期的实现原理。但实际上,由于可能存在多个同样的组件生命周期钩子,例如来自 mixins 中的生命周期钩子函数,因此我们通常需要将组件生命周期钩子序列化为一个数组,但核心原理不变。

AI 时代前端进阶:10分钟入门基于 HuggingFace Transformers 库开源模型私有化部署

大家好,我是祯民。LLM 热潮下,AI 应用对整个互联网生态都有不小的影响,每个互联网从业者都或多或少开始卷或者了解一些 AI 的内容。最近和很多同学聊了聊,发现他们都还没接触过模型私有化部署,应这些同学的需要,本文我们就来用10分钟带大家入门模型私有化部署。

全文不涉及复杂的算法知识,可以轻松食用。通过本文的学习,大家就可以部署使用任意开源模型了。

HuggingFace & Transformers

Hugging Face是一家专注于自然语言处理(NLP)领域的开源公司,以其在NLP技术、工具和社区建设方面的创新而闻名。它提供了一个社区站点,用于开源模型和数据集的共享和交流。开发者可以在社区里使用各种类别的开源模型和数据集,并能站在巨人的肩膀上,利用已有的开源模型进行训练,从而探索新的可能性。

image.jpg

简单来说,HuggingFace 是类似模型领域的 NPM 社区,我们可以基于 HuggingFace 社区部署或者微调开源模型,以扩展应用的产品能力。而 Transformers 则是 HuggingFace 社区提供的最重要的机器学习库,通过 HuggingFace 库,开发者可以快速部署微调 HuggingFace 社区的模型。

私有化部署的模型包含但不限于文本交流、多模态、情绪分析、文本向量化等类别,这些不同类别的模型可以为应用增强更大幅度的想象力。这样即使在没有专业算法同学的介入下,我们也可以做出一些更有意思的东西了~

一个图片识别模型的示例

不同类别的模型使用过程不尽相同,因此Transformers针对不同任务类别的开源模型定制了不同的工作流,也就是pipeline功能,以此来简化模型的使用流程。通过使用pipeline工作流功能,即使是非专业人员也能轻松调用一些特定任务类型的开源模型。

下面我们来一起做一个简单的图片识别模型的示例,使用的模型是Salesforce/blip-image-captioning-large

通常我们会使用 Anaconda 来维护模型部署的 python 空间,避免 python 空间之间的依赖彼此冲突。通过使用Anaconda,可以高效低成本地管理项目中的Python环境,维护多套Python环境。同时,Anaconda自带的Conda是一个强大的包管理系统,允许用户轻松安装、更新、卸载成千上万的数据科学相关软件包,对机器学习场景非常友好。

Anaconda 具体的安装大家可以自行搜寻资料完成,下面我们直接开始图片识别模型的示例,首先我们先使用 Conda 创建一个独立的Python环境。

conda create -n hugging-face-transformers python=3.11
conda activate hugging-face-transformers 

下面我们来安装 Transformers 库。Transformers 的安装需要配套的机器学习框架,这里选择Torch,它是一个拥有大量机器学习算法支持的科学计算框架,在机器学习领域应用广泛,Hugging Face中的许多模型都可以基于Torch运行,执行以下命令可以完成Transformers和Torch的安装。

pip install 'transformers[torch]'

接下来,我们来创建一个 python 脚本,使用 Transformers 库 pipeline 运行指定的图片识别模型,其中 image_path 参数替换成需要识别的图片 URL。

from transformers import pipeline
pipe = pipeline("image-to-text", model="Salesforce/blip-image-captioning-large")
image_path = "https://your_image_url" // 换成你的图片的URL
output = pipe(image_path)
print(output)

这里我用的图片是个咖啡猫的表情包,如下:

image.jpg

值得一提的是,不同的工作流可能会有不同的依赖要求,这个大家可以根据脚本提示补充安装,该场景需要补充一个 python 图像库的依赖 pillow,安装依赖如下:

pip install pillow

安装完成后,执行脚本,稍等片刻后,就能得到图片内容的自然语言描述,结果是“araffe cat looking at itself in a mirror while sitting on a bed”(阿拉菲猫坐在床上看着镜子里的自己)

image.jpg

同样的方式也适用于 HuggingFace 的其他支持 pipline 的模型,这部分在 HuggingFace 的占比很高,支持 pipeline 的模型会在 HuggingFace 中显示。

image.jpg

对于不支持 pipline 的模型,就需要通过加载指定模型分词器的方式调用了,这个我后面再找机会写篇文章和大家分享。

小结

本文我们学习了 HuggingFace Transformers 库的入门知识,了解了它的作用和如何通过支持的 pipeline 调用对应的模型,除此之外,HuggingFace Transformers 库提供了分词器、数据集等模型推理、微调相关的能力。

掌握好 Transformers 库,我们就可以为应用添加各种更有意思的能力,是 AI 时代前端进阶必不可少的能力之一。更多有意思的内容后续我也会找时间写文和大家做更多的交流分享。

这里打个广告,我前年开始写的《生成式 AI 应用开发:基于 OpenAI API 开发》实体书上架了,HuggingFace 是本书的重点章节之一,除此之外还介绍了常见的 AI 应用形态、RAG、PE、微调等知识模块,是 AI 时代进阶的入门书籍~

image.jpg

同时因为 AI 迭代迅速,对应实体书的实效性也不如其他的书籍,所以后续我会以修订电子版、社区文等方式更新更多变化,包含但不限于MCP、蒸馏、智能体 Agent等模块内容,感兴趣的同学可以支持一下~

里面有猫!Contenteditable 实现简单的富文本编辑器!


WechatIMG324.jpg

可爱的猫猫镇楼

Contenteditable 是 HTML 的一个超好用的全局属性,有了它,就好像给网页里的各种元素(如:div)都加上了神奇的 “编辑开关” 。只要把它的值设置为 true,对应的元素马上就摇身一变,成了能让用户随意编辑的区域。

它的核心原理其实也不难理解,简单来说,就是把用户输入的内容,按照 HTML 的规则,转化成对应的 HTML 结构。比如你输入一段带格式的文字,它会自动帮你生成 b 标签(加粗)、i 标签(斜体)来标记格式,真正实现了 “所见即所得” 。

基础实现

接下来,我将用 Contenteditable 搭建一个超简易的富文本编辑器,带加粗、斜体等这些基本格式设置的那种,代码量不大,新手也能轻松拿捏。

<!DOCTYPE html>
<html>

<head>
  <style>
    .editor-container {
      border: 1px solid #ddd;
      border-radius: 4px;
      padding: 10px;
      min-height: 200px;
    }

    .toolbar {
      margin-bottom: 10px;
      display: flex;
      align-items: center;
    }

    .toolbar button {
      margin-right: 5px;
      padding: 5px 10px;
      background: #f5f5f5;
      border: 1px solid #ddd;
      border-radius: 3px;
      cursor: pointer;
    }

    .toolbar button:hover {
      background: #e9e9e9;
    }

    .toolbar select,input {
      margin-right: 5px;
    }

    /* 编辑器内容样式 */
    .editor-content {
      white-space: pre-wrap;
      outline: none;
    }
  </style>
</head>

<body>
  <div class="toolbar">
    <button onclick="toggleBold()">B</button>
    <button onclick="toggleItalic()">I</button>
    <button onclick="toggleUnderline()">U</button>
    <!-- 样式设置 -->
    <select id="fontSize" onchange="setFontSize(this.value)">
      <option value="12">12px</option>
      <option value="14">14px</option>
      <option value="16" selected>16px</option>
      <option value="18">18px</option>
    </select>
    <input type="color" id="textColor" onchange="setTextColor(this.value)">
    <button onclick="insertLink()">链接</button>
    <button onclick="insertImage()">图片</button>
    <button onclick="undo()">撤销</button>
    <button onclick="redo()">重做</button>
  </div>

  <div class="editor-container" contenteditable="true" id="editor"></div>

  <script>
    document.execCommand('fontSize', false, 4);
    // 基础功能实现
    function toggleBold() {
      document.execCommand('bold', false, null);
    }

    function toggleItalic() {
      document.execCommand('italic', false, null);
    }

    function toggleUnderline() {
      document.execCommand('underline', false, null);
    }

    // 设置字体大小
    function setFontSize(size) {
      let fontSize = size == 12 ? 2 : (size == 14 ? 3 : (size == 16 ? 4 : 5))
      document.execCommand('fontSize', false, fontSize);
    }

    // 设置文本颜色
    function setTextColor(color) {
      document.execCommand('foreColor', false, color);
    }

    function insertLink() {
      const url = prompt('请输入链接地址:');
      if (url) {
        document.execCommand('createLink', false, url);
      }
    }

    function insertImage() {
      const imgUrl = prompt('请输入图片地址:');
      if (imgUrl) {
        document.execCommand('insertImage', false, imgUrl);
      }
    }

    // 撤销/重做
    function undo() {
      document.execCommand('undo', false, null);
    }

    function redo() {
      document.execCommand('redo', false, null);
    }

    // 内容获取与保存
    function getContent() {
      return document.getElementById('editor').innerHTML;
    }

    function saveContent() {
      localStorage.setItem('editorContent', getContent());
      alert('内容已保存到本地存储');
    }

    // 加载历史内容
    window.addEventListener('load', () => {
      const savedContent = localStorage.getItem('editorContent');
      if (savedContent) {
        document.getElementById('editor').innerHTML = savedContent;
      }
    });
  </script>
</body>

</html>

代码主要分为 3 个部分,最上面通过 style 标签包裹的是 CSS 部分,主要控制编辑器整体的样式,中间是 HTML 部分,主要由一系列按钮以及最重要的「编辑器」的 div 盒子组成,最下面 script 标签中则是 HTML 中按钮相对应的 JS 逻辑。

这块有两个关键点,一个就是给作为「编辑器」的 div 设置 contenteditable = true,这样它就成了一个可编辑的区域;另一个关键点就是 document.execCommand 方法,通过它来实现编辑器的各个功能。

image.png

上图就是这个简易编辑器的页面样式,主要包含了:加粗、斜体、下划线、字号、颜色、链接、图片、撤销、恢复等功能。这样,一个简单的富文本编辑器就基本成型啦!是不是很方便!

实际开发痛点

虽然我实际的工作过程中并不做富文本编辑器这方面的工作,但这块的工作之复杂、繁琐也是早有耳闻。使用 Contenteditable 富文本编辑器在实际开发中,会遇到不少让人头疼的问题,如:光标和选区问题,有用过的小伙伴可能有切身体会,我们在操作的过程中,这个光标位置和选区范围经常不太听话,明明看着光标在文字中间,获取到的位置信息却不对;选中文本后执行操作,效果也不是我们预期的那样。这主要是因为不同浏览器对光标和选区的处理有差异,再加上 Contenteditable 自身机制的复杂性,就容易出现各种问题。

要解决这个问题,我们可以尝试借助 Range 和 Selection 这两个 JavaScript 对象。Range 对象能精确表示文档中的一个区域,通过它可以获取和操作选区的起始位置、结束位置等信息 。Selection 对象则代表用户当前选中的区域,比如可以用它来判断是否有文本被选中,以及获取选中的文本内容。

优缺点

Contenteditable 的优点是非常明显的:

  • 简单易用,开箱即用
  • 轻量级,性能佳
  • 原生支持键盘操作
  • 实时生成 HTML代码

但是它的缺点不可忽视:

  • 浏览器兼容性问题
  • 复杂格式控制困难
  • 粘贴富文本内容容易导致代码冗余
  • 移动端兼容性

因此,如果只是实现简单的富文本编辑功能,Contenteditable 是个不错的选择,能满足很多基础场景的需求;但要是遇到复杂的功能要求,还是推荐使用像 TinyMCE、Quill 这样成熟的富文本编辑库 。不过,掌握 Contenteditable 的原理,能让我们在面对定制化需求时,有更灵活的应对思路。

以上,就是 Contenteditable 实现简单富文本编辑器相关的全部内容,如果对您有帮助,那是对我最好的激励!如果您感兴趣的话,点个关注吧!我会持续更新的!

【VUE】在vue中,Watcher与Dep的关系

关于vue中的WatcherDep的关系讨论。
这两者相互依赖,形成了一个比较完备的响应式系统。

  1. 一个组件实例对应一个Watcher(负责视图更新)
  2. 每个响应式属性,对应一个Dep
  3. 计算属性(computed)以及监听器(watch)都会创建各自的 watcher

1.数据属性 & Dep

data() {
  return {
    count: 0,    // 会创建一个 Dep
    user: {      // user 对象和其每个属性都会创建各自的 Dep
      name: 'John',  // 一个 Dep
      age: 25     // 一个 Dep
    }
  }
}

对于一个对象,自身有一个Dep,其内在的每个属性都会有一个Dep,所以上述的user对象中,存在3个Dep。

2.watcher的类型

  1. 每个组件实例,都会创建一个watcher
  2. 依赖属性存在计算属性computed,也会存在一个watcher
  3. 监听器watch

3.依赖收集的过程

访问时的过程 ——> getter

<div>{{ count }}</div>

// 渲染 Watcher 执行前,Dep.target 设置为渲染 Watcher
// 访问 count 时:
1. count 的 getter 被触发
2. 将当前 Dep.target (渲染 Watcher) 添加到 count 的 Dep3. 同时这个 Watcher 也会记录这个 Dep
Component Instance
│
├── Render Watcher (负责视图更新)
│   ├── 订阅了 count 的 Dep
│   ├── 订阅了 user.nameDep
│   └── 订阅了其他模板中用到的数据的 Dep
│
├── Computed Watcher (如果有计算属性)
│   ├── 订阅了计算依赖的数据的 Dep
│   └── ...
│
└── User Watcher (如果有 watch 选项)
    ├── 订阅了被监听数据的 Dep
    └── ...

触发更新时的过程 ——> setter

当数据变化时:

  1. 数据的 setter 被触发
  2. 通知对应的 Dep
  3. Dep 通知所有订阅它的 Watcher
  4. Watcher 根据类型执行不同操作:
    • 渲染 Watcher → 触发组件重新渲染
    • 计算属性 Watcher → 重新计算
    • 用户 Watcher → 执行回调函数

总结起来
一个Watcher中存在多个Dep,依组件中的data中的属性而看
一个Dep中也可存在多个Watcher,如果此时的属性被computed或者watch依赖,那么就会存在多个Watcher

达成成就 - Nuxt Contributor 😁

过了很久才决定写下这篇文章,记录一下参与Nuxt的贡献历程,没想到有一天可以参与到 Nuxt 56.8k star (2023.04 ~ 2025.04)的贡献当中,也权当做一次这几年的回顾。

前情提要

2023年做一个服务端渲染的项目,从Nuxt地球最强无对手ssr框架两个框架中,都做了项目试点。最终决定后续项目都使用Nuxt去做,对于Vue开发者的使用上来讲,使用Nuxt框架更简单,版本更新迭代更快。当然只是更主观意识的选择,两个框架都很好。

参与贡献

第一次PR

其实为什么能参与贡献,主要是因为项目中正好碰到了这个问题,但是并不是一个很大的问题。

其实只是在执行preview的时候,配置的路径问题

nitro: { output: { dir: '~/node-server' } }

会提示找不到nitro.json,当时在我比较肤浅的眼光看来,这就是一个路径问题,处理一下~/就可以解决这个了~

然后秉承着这个问题我可以解决的态度,然后根据Nuxt contribution guide(当时还是个README),提交了如下代码

const serverOutputDir = config.nitro.output?.dir?.replace(/^~\//, '') || '.output'

第一次参与这种大型项目的代码贡献,心里很忐忑(好歹不是简单的改个文档~)

不过并没有那么简单,哈哈哈😁

在我提完这个PR后,得到了danielroe(Nuxt Maintainer)很明确的说明,这样可能会有其他额外的开销问题,他不愿意这样合并。当然,后续得到了pi0的解释,预览功能在本地,有开销也可以接受,他就同意了本次的修改,本来以为approve之后,我的改动会被合并进去~心里还挺激动的😄,毕竟参与了这么大型的开源项目,理想很丰满,现实很骨感,最终只有两行引入是我改动的,其他的处理被danielroe改掉了😭,也勉强算是一次有效的参与吧。

但是第一次参与大型开源项目,还是这么大的项目,心里肯定是相当激动的~能合并进去,对我来说也是很强的鼓励了。

详情可见:fix(nuxi): preview nitro build with custom dir config

第一次Fix的PR

然后一直基本到2024.07之前,基本都是碰到文档问题,不太清晰明了的,去帮助做一下修改。

2024.07正好碰到一个我可以处理的definePageMeta属性合并问题,这回靠自己处理好了

详情可见:fix(nuxt): merge route meta properties with scanned meta

第一次Feat的PR

2024.10正好使用definePageMeta,然后发现跟vue-route结合还是挺紧密的,但是却没有props的设置,查看了一下源码,发现其实加起来也并不麻烦,所以就进行了一次增强

-- pages
   -- [params]
      -- [id].vue

// [id].vue
<script setup lang="ts">
definePageMeta({
  props: true,
})
const { params, id } = defineProps<{
  params: string
  id: string
}>()

console.log(params, id)
</script>

详情可见:feat(nuxt): allow enabling route props in definePageMeta

2024 ~ 2025

后面就是在项目的使用过程中碰到的问题,自己可以解决的,就去fix一下,文档显示不清楚的,就去修改一下,有标注enhancement的,看过之后会加的,就feat一下~

感触

其实没想到不知不觉都过了两年时光了(2023 ~ 2025),与Nuxt的纠缠也越来越深了,基本现在开发所有的业务项目,都是使用Nuxt,对于我们来讲,约定>配置,确实方便了开发,降低了开发难度,心智负担,但是想写好还是有难度的。

最后对于参与开源这个事情,迈出第一步其实还是挺难的,但是你参与过后,发现也并没有很难,即使改个文档,能被合并也是你的能力,你能发现别人没有发现的问题~ 一起加油吧!!

最后放两张图吧,互勉❤

image.png

image.png

❌