普通视图

发现新文章,点击刷新页面。
今天 — 2025年9月27日首页

实现AI对话光标跟随效果

作者 gnip
2025年9月27日 00:29

概述

在使用一些AI对话工具的时候,比如gtp的聊天,在内容不断生成过程中,末尾会有光标跟随的特效,标识当前的实时位置,下面我们自己模拟实现一下。

效果

动画.gif

实现思路

  • 首先聊天内容是实时不断更新的过程,实现通过模拟数据生成
  • 要实现跟随文本生成最后位置生成一个圆点(自定义),需要找到最后一个文本节点
  • 然后追加一个文本
  • 获取文本相对页面的位置信息
  • 设置光标dom元素到上面的位置
  • 最后删除多余的文本

涉及到的DOM API

如下两个API在我们获取位置的时候非常关键,可以自行查阅相关用法

  • getBoundingClientRect
  • document.createRange

实现

生成聊天内容

使用如下测试数据

 const str = `核心对比:现代公共性观赏 vs 古代私人雅集式观赏。 
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        作者态度是:
        现代公共观赏是主流,若强装古人雅集式观赏会被嘲笑。
        选项分析:
        历史越往后发展,艺术品越具有公共性——文段
        中国人艺术修为在不断进化——文段没有谈修为进化然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        我们分析一下文段结构:
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
       然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        `
        
           function transformTag(str) {
            return str.split("\n").map(t => `<p>${t}</p>`).join("")
        }

延迟函数

一个简单的Promise应用

   function delay(duration) {
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve();
                }, duration);
            });
        }

获取最后一文本节点

      function getLastTextNode(node) {
            if (node.nodeType === Node.TEXT_NODE) {
                return node
            }
            const childNodeList = Array.from(node.childNodes)
            for (let i = childNodeList.length - 1; i >= 0; i--) {
                const child = childNodeList[i]
                const res = getLastTextNode(child)
                if (res) {
                    return res
                }
            }
            return null
        }

更新光标位置

    function updateCursor() {
            const lastTextNode = getLastTextNode(wrapper)
            const curSorNode = document.createTextNode("|")
            if (lastTextNode) {
                lastTextNode.after(curSorNode)
            } else {
                wrapper.appendChild(curSorNode)
            }
            // 获取光标位置元素节点位置

            const range = document.createRange();
            range.setStart(curSorNode, 0);
            range.setEnd(curSorNode, 0);
            const rect = range.getBoundingClientRect();
            const wrapperRect = wrapper.getBoundingClientRect()

            const left = rect.left - wrapperRect.left
            const top = rect.top - wrapperRect.top
            console.log("rect", rect)
            // 设置光标位置
            if (!dot) {
                dot = document.createElement("span")
                dot.className = "blinking-dot"
                document.body.appendChild(dot)
            }
            const dotRect = dot.getBoundingClientRect()
            dot.style.left = rect.left + "px"
            dot.style.top = rect.top + rect.height / 2 - dotRect.height / 2 + "px"
            curSorNode.remove()

        }

渲染开始

 async function renderContent() {
            for (let i = 0; i < str.length; i++) {
                const text = str.slice(0, i);
                const html = transformTag(text)
                wrapper.innerHTML = html
                updateCursor()
                await delay(180)

            }
        }
        renderContent()

样式

    .blinking-dot {
            width: 15px;
            height: 15px;
            background-color: #000;
            /* 圆点颜色 */
            border-radius: 50%;
            position: fixed;
            /* 圆形 */
            animation: blink 0.8s infinite;
            /* 动画设置 */
            box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
            /* 可选的光晕效果 */
        }

        /* 闪烁动画定义 */
        @keyframes blink {
            0% {
                opacity: 1;
                /* 完全显示 */
                transform: scale(1);
                /* 正常大小 */
            }

            50% {
                opacity: 0.3;
                /* 半透明 */
                transform: scale(0.8);
                /* 稍微缩小 */
            }

            100% {
                opacity: 1;
                /* 恢复完全显示 */
                transform: scale(1);
                /* 恢复大小 */
            }
        }

完整代码

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

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <style>
        .blinking-dot {
            width: 15px;
            height: 15px;
            background-color: #000;
            /* 圆点颜色 */
            border-radius: 50%;
            position: fixed;
            /* 圆形 */
            animation: blink 0.8s infinite;
            /* 动画设置 */
            box-shadow: 0 0 10px rgba(255, 0, 0, 0.5);
            /* 可选的光晕效果 */
        }

        /* 闪烁动画定义 */
        @keyframes blink {
            0% {
                opacity: 1;
                /* 完全显示 */
                transform: scale(1);
                /* 正常大小 */
            }

            50% {
                opacity: 0.3;
                /* 半透明 */
                transform: scale(0.8);
                /* 稍微缩小 */
            }

            100% {
                opacity: 1;
                /* 恢复完全显示 */
                transform: scale(1);
                /* 恢复大小 */
            }
        }
    </style>
</head>

<body>
    <!-- 内容容器 -->
    <div class="wrapper"></div>
    
    <script>
        // 要显示的文本内容
        const str = `核心对比:现代公共性观赏 vs 古代私人雅集式观赏。 
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        作者态度是:
        现代公共观赏是主流,若强装古人雅集式观赏会被嘲笑。
        选项分析:
        历史越往后发展,艺术品越具有公共性——文段
        中国人艺术修为在不断进化——文段没有谈修为进化然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        我们分析一下文段结构:
        开头引用民间说法和《本草纲目》,指出大蒜对眼睛有害。然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        接着从临床经验、现代医学、中医理论多角度解释为什么有害。
        然后不仅讲对眼睛的害处,还提到蒜是发物,会刺激加重其他疾病(如肠炎)。
        `;
        
        // 获取内容容器元素
        const wrapper = document.querySelector('.wrapper');
        // 用于存储闪烁圆点的引用
        let dot = null;
        
        /**
         * 延迟函数,返回一个Promise,在指定时间后resolve
         * @param {number} duration 延迟时间(毫秒)
         * @returns {Promise} 延迟完成的Promise
         */
        function delay(duration) {
            return new Promise((resolve) => {
                setTimeout(() => {
                    resolve();
                }, duration);
            });
        }
        
        /**
         * 将文本转换为HTML段落
         * @param {string} str 要转换的文本
         * @returns {string} 转换后的HTML字符串
         */
        function transformTag(str) {
            // 按换行符分割文本,每行用<p>标签包裹
            return str.split("\n").map(t => `<p>${t}</p>`).join("");
        }
        
        /**
         * 异步渲染内容,实现打字机效果
         */
        async function renderContent() {
            // 逐个字符显示文本
            for (let i = 0; i < str.length; i++) {
                // 获取当前要显示的文本部分
                const text = str.slice(0, i);
                // 转换为HTML格式
                const html = transformTag(text);
                // 更新容器内容
                wrapper.innerHTML = html;
                // 更新光标位置
                updateCursor();
                // 延迟一段时间,控制打字速度
                await delay(180);
            }
        }
        
        /**
         * 递归查找DOM节点中的最后一个文本节点
         * @param {Node} node 要查找的节点
         * @returns {Node|null} 找到的文本节点或null
         */
        function getLastTextNode(node) {
            // 如果当前节点是文本节点,直接返回
            if (node.nodeType === Node.TEXT_NODE) {
                return node;
            }
            
            // 获取所有子节点并转换为数组
            const childNodeList = Array.from(node.childNodes);
            
            // 从后往前遍历子节点
            for (let i = childNodeList.length - 1; i >= 0; i--) {
                const child = childNodeList[i];
                // 递归查找子节点中的最后一个文本节点
                const res = getLastTextNode(child);
                if (res) {
                    return res;
                }
            }
            
            // 如果没有找到文本节点,返回null
            return null;
        }
        
        /**
         * 更新光标位置
         */
        function updateCursor() {
            // 查找最后一个文本节点
            const lastTextNode = getLastTextNode(wrapper);
            // 创建光标节点(竖线符号)
            const curSorNode = document.createTextNode("|");
            
            // 如果找到文本节点,将光标插入其后
            if (lastTextNode) {
                lastTextNode.after(curSorNode);
            } else {
                // 如果没有文本节点,将光标添加到容器末尾
                wrapper.appendChild(curSorNode);
            }
            
            // 创建Range对象用于获取光标位置
            const range = document.createRange();
            range.setStart(curSorNode, 0); // 设置Range起点
            range.setEnd(curSorNode, 0);   // 设置Range终点
            // 获取光标位置信息
            const rect = range.getBoundingClientRect();
            // 获取容器位置信息
            const wrapperRect = wrapper.getBoundingClientRect();
            
            // 计算相对于容器的位置
            const left = rect.left - wrapperRect.left;
            const top = rect.top - wrapperRect.top;
            console.log("光标位置:", rect);
            
            // 创建或更新闪烁圆点
            if (!dot) {
                // 如果圆点不存在,创建新元素
                dot = document.createElement("span");
                dot.className = "blinking-dot";
                document.body.appendChild(dot);
            }
            
            // 获取圆点尺寸
            const dotRect = dot.getBoundingClientRect();
            // 设置圆点位置:水平位置与光标对齐,垂直位置与光标中心对齐
            dot.style.left = rect.left + "px";
            dot.style.top = rect.top + rect.height / 2 - dotRect.height / 2 + "px";
            
            // 移除临时光标节点
            curSorNode.remove();
        }
        
        // 页面加载完成后开始渲染内容
        window.addEventListener('DOMContentLoaded', () => {
            renderContent();
        });
    </script>
</body>
</html>
昨天以前首页

工作常用设计模式

作者 gnip
2025年9月25日 00:21

概述

设计模式作为软件工程中解决特定问题的经典方案,虽然设计模式很多,对于前端开发来说,实际业务工程项目中常用的就那么几个,见下。

1. 单例模式 (Singleton Pattern)

确保一个类只有一个实例,并提供全局访问点。

应用场景:全局状态管理、模态框、提示组件等

class AuthService {
  constructor() {
    if (AuthService.instance) {
      return AuthService.instance;
    }
    this.token = null;
    this.user = null;
    AuthService.instance = this;
  }

  login(token, user) {
    this.token = token;
    this.user = user;
  }

  logout() {
    this.token = null;
    this.user = null;
  }

  isAuthenticated() {
    return !!this.token;
  }
}

// 使用示例
const auth1 = new AuthService();
const auth2 = new AuthService();
console.log(auth1 === auth2); // true

auth1.login('token123', { name: 'John' });
console.log(auth2.isAuthenticated()); // true

2. 发布-订阅模式

观察者模式的变体,通过消息通道进行通信,发布者和订阅者不需要知道彼此的存在。

class PubSub {
  constructor() {
    this.topics = {};
    this.subId = 0;
  }

  subscribe(topic, callback) {
    if (!this.topics[topic]) {
      this.topics[topic] = {};
    }
    
    const id = ++this.subId;
    this.topics[topic][id] = callback;
    
    return {
      unsubscribe: () => {
        delete this.topics[topic][id];
        if (Object.keys(this.topics[topic]).length === 0) {
          delete this.topics[topic];
        }
      }
    };
  }

  publish(topic, data) {
    if (!this.topics[topic]) return;
    
    Object.values(this.topics[topic]).forEach(callback => {
      callback(data);
    });
  }
}

// 使用示例
const pubsub = new PubSub();

// 订阅
const subscription = pubsub.subscribe('user.login', (user) => {
  console.log('用户登录:', user);
});

// 发布
pubsub.publish('user.login', { name: 'Alice', id: 1 });

// 取消订阅
subscription.unsubscribe();

3. 工厂模式

创建对象而不暴露创建逻辑,通过工厂方法来创建对象。

应用场景:复杂对象创建、根据不同条件创建不同实例

class Dialog {
  constructor(type, content) {
    this.type = type;
    this.content = content;
  }

  show() {
    console.log(`显示${this.type}对话框: ${this.content}`);
  }
}

class DialogFactory {
  static createDialog(type, content) {
    switch (type) {
      case 'success':
        return new Dialog('成功', content);
      case 'error':
        return new Dialog('错误', content);
      case 'warning':
        return new Dialog('警告', content);
      default:
        throw new Error('未知的对话框类型');
    }
  }
}

// 使用示例
const successDialog = DialogFactory.createDialog('success', '操作成功!');
const errorDialog = DialogFactory.createDialog('error', '操作失败!');

successDialog.show(); // 显示成功对话框: 操作成功!
errorDialog.show();   // 显示错误对话框: 操作失败!

4. 策略模式

定义一系列算法,将它们封装起来,并且使它们可以相互替换。

应用场景:表单验证、支付方式等

// 策略类
const validationStrategies = {
  isRequired: (value) => ({
    isValid: value !== undefined && value !== null && value !== '',
    message: '该字段为必填项'
  }),
  
  isEmail: (value) => ({
    isValid: /^[^\s@]+@[^\s@]+.[^\s@]+$/.test(value),
    message: '请输入有效的邮箱地址'
  }),
  
  minLength: (value, length) => ({
    isValid: value.length >= length,
    message: `长度不能少于${length}个字符`
  }),
  
  maxLength: (value, length) => ({
    isValid: value.length <= length,
    message: `长度不能超过${length}个字符`
  })
};

// 验证器类
class Validator {
  constructor() {
    this.rules = [];
  }

  addRule(field, strategy, ...params) {
    this.rules.push({ field, strategy, params });
    return this;
  }

  validate(data) {
    const errors = {};
    
    this.rules.forEach(rule => {
      const value = data[rule.field];
      const result = validationStrategies[rule.strategy](value, ...rule.params);
      
      if (!result.isValid) {
        if (!errors[rule.field]) {
          errors[rule.field] = [];
        }
        errors[rule.field].push(result.message);
      }
    });
    
    return {
      isValid: Object.keys(errors).length === 0,
      errors
    };
  }
}

// 使用示例
const validator = new Validator()
  .addRule('username', 'isRequired')
  .addRule('username', 'minLength', 3)
  .addRule('email', 'isRequired')
  .addRule('email', 'isEmail');

const data = {
  username: 'ab',
  email: 'invalid-email'
};

const result = validator.validate(data);
console.log(result);

5. 装饰器模式

动态地给对象添加额外的职责,而不改变对象本身。

应用场景:功能扩展、高阶组件、vue2类组件。

// 基础组件
class Component {
  render() {
    return '基础组件';
  }
}

// 装饰器基类
class Decorator {
  constructor(component) {
    this.component = component;
  }

  render() {
    return this.component.render();
  }
}

// 具体装饰器
class BorderDecorator extends Decorator {
  render() {
    return `带有边框的(${super.render()})`;
  }
}

class ColorDecorator extends Decorator {
  constructor(component, color) {
    super(component);
    this.color = color;
  }

  render() {
    return `<span style="color: ${this.color}">${super.render()}</span>`;
  }
}

// 使用示例
let component = new Component();
component = new BorderDecorator(component);
component = new ColorDecorator(component, 'red');

console.log(component.render());
// <span style="color: red">带有边框的(基础组件)</span>

6. 适配器模式

将一个类的接口转换成客户希望的另一个接口。

应用场景:API兼容、第三方库适配、数据格式转换

// 老版本API
class OldAPI {
  request(data) {
    return `老API响应: ${JSON.stringify(data)}`;
  }
}

// 新版本API(期望的接口)
class NewAPI {
  fetch(options) {
    return `新API响应: ${options.url} - ${JSON.stringify(options.body)}`;
  }
}

// 适配器
class APIAdapter {
  constructor(oldAPI) {
    this.oldAPI = oldAPI;
  }

  fetch(options) {
    // 将新API的调用方式适配到老API
    const oldStyleData = {
      method: options.method || 'GET',
      path: options.url,
      body: options.body
    };
    
    return this.oldAPI.request(oldStyleData);
  }
}

// 使用示例
const oldAPI = new OldAPI();
const adapter = new APIAdapter(oldAPI);

// 使用新API的接口调用老API的功能
const result = adapter.fetch({
  url: '/api/users',
  method: 'POST',
  body: { name: 'John' }
});

console.log(result);

7. 代理模式

为其他对象提供一种代理以控制对这个对象的访问。

应用场景:缓存代理、权限控制、虚拟代理、数据验证

// 真实服务
class ImageService {
  constructor(filename) {
    this.filename = filename;
    this.loadImage(); // 模拟耗时的图片加载
  }

  loadImage() {
    console.log(`加载图片: ${this.filename}`);
  }

  display() {
    console.log(`显示图片: ${this.filename}`);
  }
}

// 代理
class ImageProxy {
  constructor(filename) {
    this.filename = filename;
    this.realService = null;
  }

  display() {
    if (!this.realService) {
      this.realService = new ImageService(this.filename);
    }
    this.realService.display();
  }
}

// 使用示例
const image1 = new ImageProxy('photo1.jpg');
const image2 = new ImageProxy('photo2.jpg');

// 只有在真正需要显示时才加载图片
image1.display(); // 第一次调用会加载图片
image1.display(); // 第二次直接使用已加载的图片
❌
❌