普通视图

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

记一道有趣的面试题

作者 RadiumAg
2025年7月8日 23:53

这两天面试,遇到一道有趣的手写题,其实一开始挺懵逼的,因为是网页写代码,也不好调试,到最后也就完成了大概,还有剩下的缓存任务当只有一个job的判断没完成就自动交卷了。

题目如下

实现一个Scheduler,并发限制2个任务运行,

class Scheduler {

  constructor() {}

  add(task) {
   
  }
}

/* 测试代码,请勿修改 */
const timeout = (time) =>
  new Promise((resolve) => {
    setTimeout(resolve, time);
  });

const scheduler = new Scheduler();
const addTask = (time, order) => {
  scheduler.add(() => timeout(time)).then(() => console.log(order));
};

addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');

// output: 2 3 1 4
// 一开始,1、2两个任务进入队列
// 500ms时,2完成,输出2,任务3进队
// 800ms时,3完成,输出3,任务4进队
// 1000ms时,1完成,输出1
// 1200ms时,4完成,输出4

参考答案

class Scheduler {
  jobArray = [];
  runJobsArray = [];
  freshing = false;
  isFrshing = false;
  seenJobArray = [];

  constructor() {}

  runJobs() {
    if (this.runJobsArray.length < 2) {
      const currentJob = this.jobArray.shift();
      if (currentJob != null) {
        this.runJobsArray.push(currentJob);
      } else if (currentJob == null) {
        const j = this.runJobsArray[0];
        const senndJobObj = this.seenJobArray.find((s) => s.j === j);

        if (senndJobObj) {
          senndJobObj.promise.then((res) => {
            j.resolve(res);
          });
        }
      }
    }

    if (this.runJobsArray.length === 2 && !this.isFrshing) {
      this.isFrshing = true;

      Promise.race(
        this.runJobsArray.map((j) => {
          const senndJobObj = this.seenJobArray.find((s) => s.j === j);

          if (senndJobObj) {
            return senndJobObj.promise.then((res) => {
              return {
                res,
                resolve: j.resolve,
              };
            });
          }

          const promise = j.task();

          if (senndJobObj == null) {
            this.seenJobArray.push({ j, promise });
          }

          return promise.then((res) => {
            return {
              res,
              resolve: j.resolve,
            };
          });
        })
      ).then((res) => {
        res.resolve(res);
        this.isFrshing = false;
        this.runJobsArray = this.runJobsArray.filter(
          (r) => r.resolve !== res.resolve
        );

        this.runJobs();
      });
    }
  }

  add(task) {
    let resolveValue;
    const promise = new Promise((resolve) => {
      resolveValue = resolve;
    });

    this.jobArray.push({ task, resolve: resolveValue });
    this.runJobs();

    return promise;
  }
}

/* 测试代码,请勿修改 */
const timeout = (time) =>
  new Promise((resolve) => {
    // console.log('执行了', time);
    setTimeout(resolve, time);
  });

const scheduler = new Scheduler();
const addTask = (time, order) => {
  scheduler.add(() => timeout(time)).then(() => console.log(order));
};

addTask(1000, '1');
addTask(500, '2');
addTask(300, '3');
addTask(400, '4');

// 输出
// output: 2 3 1 4
// 一开始,1、2两个任务进入队列
// 500ms时,2完成,输出2,任务3进队
// 800ms时,3完成,输出3,任务4进队
// 1000ms时,1完成,输出1
// 1200ms时,4完成,输出4

React状态管理最佳实践

作者 XXUZZWZ
2025年7月8日 22:26

React状态管理最佳实践

基于TodoList项目的实际案例,本文将深入探讨React中的状态管理核心原则和最佳实践。

项目结构分析

我们的TodoList应用采用了经典的React组件化架构:

src/
├── App.jsx          # 根组件
├── main.jsx         # 应用入口
└── components/
    └── Todos/
        ├── index.jsx    # 主要状态管理
        ├── TodoForm.jsx # 表单组件
        ├── TodoList.jsx # 列表组件
        └── TodoItem.jsx # 单项组件

核心状态管理原则

1. 单向数据流与状态提升

Todos/index.jsx中,我们可以看到状态管理的核心实现:

const [todos, setTodos] = useState([
  { id: 1, title: '学习react', isCompleted: false },
  { id: 2, title: '学习vue', isCompleted: false }
]);

关键原则

  • 状态向上提升:所有需要在多个子组件间共享的状态都应该定义在共同的父组件中
  • 单向数据流:数据通过props向下传递,事件通过回调函数向上传递

2. 子组件状态变更的正确方式

错误做法:子组件直接修改父组件传递的状态

// ❌ 错误:子组件直接修改props
const TodoItem = ({ todo }) => {
  const handleToggle = () => {
    todo.isCompleted = !todo.isCompleted; // 错误!
  }
}

正确做法:通过回调函数传递标识符

// ✅ 正确:通过回调传递id,让父组件处理状态变更
const TodoItem = ({ todo, onToggle }) => {
  const handleToggle = () => {
    onToggle(todo.id); // 传递id,利用闭包
  }
}

// 父组件中的处理函数
const toggleTodo = (id) => {
  setTodos(prevTodos => 
    prevTodos.map(todo => 
      todo.id === id 
        ? { ...todo, isCompleted: !todo.isCompleted }
        : todo
    )
  );
}

3. 不可变数据更新模式

React要求状态更新必须是不可变的,这确保了组件能够正确地检测到状态变化并重新渲染。

对象属性更新

// ✅ 使用展开运算符创建新对象
setUser(prevUser => ({
  ...prevUser,
  isLoading: !prevUser.isLoading
}));

// ❌ 直接修改原对象
user.isLoading = !user.isLoading;
setUser(user);

数组操作

// 添加新项
setTodos(prevTodos => [...prevTodos, newTodo]);

// 删除项
setTodos(prevTodos => prevTodos.filter(todo => todo.id !== targetId));

// 更新项
setTodos(prevTodos => 
  prevTodos.map(todo => 
    todo.id === targetId 
      ? { ...todo, isCompleted: !todo.isCompleted }
      : todo
  )
);

4. 受控组件与表单处理

React中的表单元素需要手动绑定状态才能实现响应式更新:

const TodoForm = ({ onAddTodo }) => {
  const [inputText, setInputText] = useState('');
  
  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      if (inputText.trim()) {
        onAddTodo(inputText);
        setInputText(''); // 清空输入
      }
    }}>
      <input 
        value={inputText}
        onChange={(e) => setInputText(e.target.value)}
        placeholder="添加新的todo项"
      />
      <button type="submit">添加</button>
    </form>
  );
};

关键点

  • value={inputText}:将input的值绑定到state
  • onChange:每次输入时更新state
  • 这样确保了输入框的值始终与组件状态同步

完整的TodoList实现示例

基于以上原则,完整的TodoList组件应该这样实现:

const Todos = () => {
  const [todos, setTodos] = useState([
    { id: 1, title: '学习react', isCompleted: false },
    { id: 2, title: '学习vue', isCompleted: false }
  ]);

  const addTodo = (title) => {
    const newTodo = {
      id: Date.now(), // 简单的id生成
      title,
      isCompleted: false
    };
    setTodos(prevTodos => [...prevTodos, newTodo]);
  };

  const toggleTodo = (id) => {
    setTodos(prevTodos => 
      prevTodos.map(todo => 
        todo.id === id 
          ? { ...todo, isCompleted: !todo.isCompleted }
          : todo
      )
    );
  };

  const deleteTodo = (id) => {
    setTodos(prevTodos => prevTodos.filter(todo => todo.id !== id));
  };

  return (
    <div className="app">
      <TodoForm onAddTodo={addTodo} />
      <TodoList 
        todos={todos}
        onToggle={toggleTodo}
        onDelete={deleteTodo}
      />
    </div>
  );
};

总结

React状态管理的核心在于:

  1. 状态提升:将共享状态定义在合适的父组件中
  2. 单向数据流:数据向下传递,事件向上冒泡
  3. 不可变更新:使用展开运算符等方式创建新的状态对象
  4. 受控组件:手动绑定表单元素的值和变更事件
  5. 职责分离:子组件专注于UI展示,父组件负责状态管理

遵循这些原则,可以构建出易于维护、性能良好的React应用。

void 0 的奥秘:解锁 JavaScript 中 undefined 的正确打开方式

作者 烛阴
2025年7月8日 22:11

一、 理解 void 0

1.1 什么是 void 运算符?

void 是 JavaScript 中的一个运算符,它接受一个表达式作为操作数,总是返回 undefined,无论操作数是什么。

console.log(void 0);      // undefined
console.log(void 1);      // undefined
console.log(void "hello"); // undefined
console.log(void {});     // undefined

1.2 为什么使用 void 0 而不是 undefined?

在 ES5 之前,undefined 不是保留字,可以被重写:

// 在ES3环境中可能出现的危险情况
var undefined = "oops";
console.log(undefined); // "oops" 而不是预期的 undefined

void 0 则始终可靠地返回真正的 undefined 值:

var undefined = "oops";
console.log(void 0); // undefined (不受影响)

1.3 现代 JavaScript 还需要 void 0 吗?

ES5 及以后版本中,undefined 是不可写、不可配置的全局属性:

// 现代浏览器中
undefined = "oops";
console.log(undefined); // undefined (不会被修改)

二、void 0 的实用场景

2.1 最小化场景:减少代码体积

void 0undefined 更短,在需要极致压缩代码时很有用:

// 原始代码
function foo(param) {
  if (param === undefined) {
    param = 'default';
  }
}

// 压缩后(使用 void 0)
function foo(n){if(n===void 0)n="default"}

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

传统 IIFE 写法:

(function() {
  // 代码
})();

使用 void 的 IIFE:

void function() {
  // 代码
}();

2.3 箭头函数中避免返回值

当需要箭头函数不返回任何值时:

let func =  () => {
  return new Promise((resolve, reject) => {
    setTimeout(resolve(5));
  })
};
// 会返回 func 的 Promise
const logData = func();

// 明确不返回值
const logData = void func();

三、常见的 void 0 误区

3.1 与 undefined和null的严格比较

console.log(void 0 === undefined); // true
console.log(void 0 === null);     // false

3.2 与 void 其他表达式

let count = 0;
void ++count;
console.log(count); // 1 (表达式仍会执行)

总结

如果你喜欢本教程,记得点赞+收藏!关注我获取更多JavaScript开发干货。

JavaScript 事件与 React 合成事件完全指南:从入门到精通

作者 XXUZZWZ
2025年7月8日 22:00

JavaScript 事件与 React 合成事件完全指南:从入门到精通

目录

  1. JavaScript 事件基础
  2. 事件处理的发展历程
  3. 事件传播机制:捕获与冒泡
  4. 事件委托:性能优化的利器
  5. React 合成事件系统
  6. 实践案例与最佳实践

JavaScript 事件基础

什么是事件?

事件是用户与网页交互时产生的动作,比如点击按钮、输入文字、滚动页面等。JavaScript 事件系统是一种异步机制,允许我们响应这些用户行为。

为什么事件是异步的?

console.log('1');
document.getElementById('button').addEventListener('click', function() {
    console.log('按钮被点击了');
});
console.log('2');
// 输出: 1, 2, 然后当按钮被点击时输出 "按钮被点击了"

事件处理是异步的,这意味着:

  • 主程序不会等待事件发生
  • 事件发生时,回调函数会被放入事件循环(Event Loop)中执行
  • 这确保了界面的响应性

事件处理的发展历程

DOM0 事件(内联事件处理)

最早期的事件处理方式,直接在 HTML 中定义:

<!-- 不推荐的方式 -->
<button onclick="alert('Hello!')">点击我</button>

<script>
// 或者在 JavaScript 中
document.getElementById('myButton').onclick = function() {
    alert('Hello!');
};
</script>

缺点:

  • HTML 和 JavaScript 耦合
  • 只能绑定一个事件处理函数
  • 难以维护

DOM1 事件

DOM1 实际上没有定义事件规范,这是一个过渡阶段。

DOM2 事件(现代标准)

引入了 addEventListener 方法,这是现在推荐的方式:

element.addEventListener(eventType, handler, useCapture);

参数说明:

  • eventType: 事件类型(如 'click', 'mouseover')
  • handler: 事件处理函数(回调函数)
  • useCapture: 是否在捕获阶段处理事件(默认 false)

优点:

  • 可以为同一事件绑定多个处理函数
  • 更好的控制事件传播
  • 可以移除事件监听器
const button = document.getElementById('myButton');

// 添加多个点击事件
button.addEventListener('click', function() {
    console.log('第一个处理函数');
});

button.addEventListener('click', function() {
    console.log('第二个处理函数');
});

// 移除事件监听器
function myHandler() {
    console.log('可移除的处理函数');
}
button.addEventListener('click', myHandler);
button.removeEventListener('click', myHandler);

事件传播机制:捕获与冒泡

事件传播的三个阶段

当你点击一个元素时,浏览器需要确定哪个元素被点击。事件传播包含三个阶段:

1. 捕获阶段:document → 根元素 → 父元素 → 目标元素
2. 目标阶段:在目标元素上触发事件
3. 冒泡阶段:目标元素 → 父元素 → 根元素 → document

可视化示例

<div id="outer">
    <div id="middle">
        <div id="inner">点击我</div>
    </div>
</div>
const outer = document.getElementById('outer');
const middle = document.getElementById('middle');
const inner = document.getElementById('inner');

// 冒泡阶段监听(默认)
outer.addEventListener('click', () => console.log('Outer - 冒泡'));
middle.addEventListener('click', () => console.log('Middle - 冒泡'));
inner.addEventListener('click', () => console.log('Inner - 冒泡'));

// 捕获阶段监听
outer.addEventListener('click', () => console.log('Outer - 捕获'), true);
middle.addEventListener('click', () => console.log('Middle - 捕获'), true);
inner.addEventListener('click', () => console.log('Inner - 捕获'), true);

// 点击 inner 元素的输出顺序:
// Outer - 捕获
// Middle - 捕获
// Inner - 捕获
// Inner - 冒泡
// Middle - 冒泡
// Outer - 冒泡

控制事件传播

// 阻止事件冒泡
element.addEventListener('click', function(event) {
    event.stopPropagation();
    console.log('事件不会继续传播');
});

// 阻止默认行为
document.getElementById('myLink').addEventListener('click', function(event) {
    event.preventDefault(); // 阻止链接跳转
    console.log('链接被点击,但不会跳转');
});

// 既阻止冒泡又阻止默认行为
element.addEventListener('click', function(event) {
    event.stopPropagation();
    event.preventDefault();
    // 或者简写为
    // return false; // 注意:只在某些情况下有效
});

事件委托:性能优化的利器

什么是事件委托?

事件委托是一种利用事件冒泡机制的技术,将事件监听器添加到父元素上,而不是每个子元素上。

为什么需要事件委托?

性能问题示例:

// 不好的做法:为每个列表项添加事件监听器
const items = document.querySelectorAll('.list-item');
items.forEach(item => {
    item.addEventListener('click', function() {
        console.log('项目被点击:', this.textContent);
    });
});
// 如果有1000个列表项,就会有1000个事件监听器!

优化后的事件委托:

// 好的做法:只在父容器上添加一个事件监听器
document.getElementById('list-container').addEventListener('click', function(event) {
    if (event.target.classList.contains('list-item')) {
        console.log('项目被点击:', event.target.textContent);
    }
});
// 只有1个事件监听器,性能大大提升!

事件委托的优势

  1. 性能优化:减少内存使用和事件监听器数量
  2. 动态内容支持:新添加的元素自动具有事件处理能力
  3. 简化代码管理:统一的事件处理逻辑

动态内容示例

const container = document.getElementById('container');

// 使用事件委托处理动态添加的按钮
container.addEventListener('click', function(event) {
    if (event.target.tagName === 'BUTTON') {
        console.log('动态按钮被点击:', event.target.dataset.id);
    }
});

// 动态添加按钮
function addButton(id, text) {
    const button = document.createElement('button');
    button.textContent = text;
    button.dataset.id = id;
    container.appendChild(button);
}

// 这些动态添加的按钮都能响应点击事件
addButton('1', '按钮1');
addButton('2', '按钮2');

实际应用场景

// 场景1:表格行的操作
document.getElementById('data-table').addEventListener('click', function(event) {
    const row = event.target.closest('tr');
    
    if (event.target.classList.contains('edit-btn')) {
        editRow(row.dataset.id);
    } else if (event.target.classList.contains('delete-btn')) {
        deleteRow(row.dataset.id);
    }
});

// 场景2:导航菜单
document.getElementById('navigation').addEventListener('click', function(event) {
    if (event.target.tagName === 'A') {
        event.preventDefault();
        const page = event.target.dataset.page;
        loadPage(page);
    }
});

// 场景3:弹窗外部点击关闭
document.addEventListener('click', function(event) {
    const modal = document.getElementById('modal');
    if (modal.style.display === 'block' && !modal.contains(event.target)) {
        modal.style.display = 'none';
    }
});

React 合成事件系统

什么是合成事件(SyntheticEvent)?

React 合成事件是对原生 DOM 事件的封装,提供了一致的 API 和更好的性能优化。

React 事件系统的特点

  1. 事件委托:所有事件都委托到根元素(React 17+ 是应用的根容器,之前版本是 document)
  2. 跨浏览器兼容性:抹平不同浏览器的差异
  3. 事件池:重用事件对象以提高性能(React 17+ 已移除)

React 中的事件处理

function MyComponent() {
    const handleClick = (event) => {
        // event 是 SyntheticEvent 对象
        console.log('事件类型:', event.type);
        console.log('目标元素:', event.target);
        console.log('当前元素:', event.currentTarget);
        
        // 访问原生事件
        console.log('原生事件:', event.nativeEvent);
        
        // 阻止默认行为和冒泡
        event.preventDefault();
        event.stopPropagation();
    };

    return (
        <div>
            <button onClick={handleClick}>
                点击我
            </button>
        </div>
    );
}

React 17+ 的重要变化

React 17 之前:

// 事件委托到 document
document.addEventListener('click', handler);

React 17+:

// 事件委托到应用的根容器
const root = document.getElementById('root');
root.addEventListener('click', handler);

这个变化的好处:

  • 多个 React 应用可以安全共存
  • 更容易与其他库集成
  • 避免了一些边缘情况的问题

事件池的演变

React 17 之前的事件池:

function OldComponent() {
    const handleClick = (event) => {
        // 错误:事件对象会被重用
        setTimeout(() => {
            console.log(event.type); // 可能会报错或显示 null
        }, 1000);
        
        // 正确做法1:持久化事件
        event.persist();
        setTimeout(() => {
            console.log(event.type); // 现在可以正常访问
        }, 1000);
        
        // 正确做法2:提取需要的属性
        const eventType = event.type;
        setTimeout(() => {
            console.log(eventType); // 安全访问
        }, 1000);
    };
    
    return <button onClick={handleClick}>点击</button>;
}

React 17+ 无需事件池:

function ModernComponent() {
    const handleClick = (event) => {
        // 现在可以安全地在异步操作中使用事件对象
        setTimeout(() => {
            console.log(event.type); // 完全没问题
        }, 1000);
    };
    
    return <button onClick={handleClick}>点击</button>;
}

实践案例与最佳实践

案例1:可切换的标签页组件

function TabComponent() {
    const [activeTab, setActiveTab] = useState(0);
    
    // 使用事件委托处理标签切换
    const handleTabClick = (event) => {
        const tabIndex = parseInt(event.target.dataset.tabIndex);
        if (!isNaN(tabIndex)) {
            setActiveTab(tabIndex);
        }
    };
    
    return (
        <div>
            <div className="tab-headers" onClick={handleTabClick}>
                {tabs.map((tab, index) => (
                    <button
                        key={index}
                        data-tab-index={index}
                        className={activeTab === index ? 'active' : ''}
                    >
                        {tab.title}
                    </button>
                ))}
            </div>
            <div className="tab-content">
                {tabs[activeTab].content}
            </div>
        </div>
    );
}

案例2:拖拽排序列表

function DragDropList() {
    const [items, setItems] = useState(['项目1', '项目2', '项目3']);
    const [draggedItem, setDraggedItem] = useState(null);
    
    const handleDragStart = (event) => {
        setDraggedItem(event.target.dataset.index);
    };
    
    const handleDragOver = (event) => {
        event.preventDefault(); // 允许放置
    };
    
    const handleDrop = (event) => {
        event.preventDefault();
        const dropIndex = event.target.dataset.index;
        const dragIndex = draggedItem;
        
        if (dragIndex !== dropIndex) {
            const newItems = [...items];
            const draggedContent = newItems[dragIndex];
            newItems.splice(dragIndex, 1);
            newItems.splice(dropIndex, 0, draggedContent);
            setItems(newItems);
        }
        
        setDraggedItem(null);
    };
    
    return (
        <div>
            {items.map((item, index) => (
                <div
                    key={index}
                    data-index={index}
                    draggable
                    onDragStart={handleDragStart}
                    onDragOver={handleDragOver}
                    onDrop={handleDrop}
                    className="drag-item"
                >
                    {item}
                </div>
            ))}
        </div>
    );
}

案例3:无限滚动加载

function InfiniteScrollList() {
    const [items, setItems] = useState([]);
    const [loading, setLoading] = useState(false);
    const containerRef = useRef(null);
    
    const loadMore = useCallback(async () => {
        if (loading) return;
        
        setLoading(true);
        try {
            const newItems = await fetchMoreItems();
            setItems(prev => [...prev, ...newItems]);
        } finally {
            setLoading(false);
        }
    }, [loading]);
    
    useEffect(() => {
        const container = containerRef.current;
        
        const handleScroll = () => {
            const { scrollTop, scrollHeight, clientHeight } = container;
            
            // 滚动到底部附近时加载更多
            if (scrollTop + clientHeight >= scrollHeight - 100) {
                loadMore();
            }
        };
        
        container.addEventListener('scroll', handleScroll);
        return () => container.removeEventListener('scroll', handleScroll);
    }, [loadMore]);
    
    return (
        <div ref={containerRef} className="scroll-container">
            {items.map((item, index) => (
                <div key={index} className="list-item">
                    {item.content}
                </div>
            ))}
            {loading && <div>加载中...</div>}
        </div>
    );
}

最佳实践总结

  1. 事件处理器命名规范

    // 好的命名
    const handleButtonClick = () => {};
    const handleFormSubmit = () => {};
    const handleInputChange = () => {};
    
    // 避免的命名
    const click = () => {};
    const submit = () => {};
    
  2. 使用 useCallback 优化性能

    const MyComponent = ({ onUpdate }) => {
        const handleClick = useCallback((event) => {
            // 事件处理逻辑
            onUpdate(event.target.value);
        }, [onUpdate]);
        
        return <button onClick={handleClick}>点击</button>;
    };
    
  3. 正确处理表单事件

    const FormComponent = () => {
        const handleSubmit = (event) => {
            event.preventDefault(); // 阻止表单默认提交
            
            const formData = new FormData(event.target);
            const data = Object.fromEntries(formData);
            
            // 处理表单数据
            submitForm(data);
        };
        
        return (
            <form onSubmit={handleSubmit}>
                <input name="username" required />
                <button type="submit">提交</button>
            </form>
        );
    };
    
  4. 合理使用事件委托

    // 当有很多相似元素时使用事件委托
    const ListComponent = ({ items }) => {
        const handleItemAction = (event) => {
            const action = event.target.dataset.action;
            const itemId = event.target.closest('[data-item-id]').dataset.itemId;
            
            switch (action) {
                case 'edit':
                    editItem(itemId);
                    break;
                case 'delete':
                    deleteItem(itemId);
                    break;
            }
        };
        
        return (
            <div onClick={handleItemAction}>
                {items.map(item => (
                    <div key={item.id} data-item-id={item.id}>
                        <span>{item.name}</span>
                        <button data-action="edit">编辑</button>
                        <button data-action="delete">删除</button>
                    </div>
                ))}
            </div>
        );
    };
    

总结

从 JavaScript 原生事件到 React 合成事件,我们经历了一个不断优化和演进的过程:

  1. 基础阶段:理解事件的异步特性和基本处理方式
  2. 进阶阶段:掌握事件传播机制和事件委托技术
  3. 高级阶段:深入 React 合成事件系统和性能优化

关键要点:

  • 事件是异步的,要善用事件循环
  • 事件委托是性能优化的重要手段
  • React 合成事件提供了更好的开发体验
  • 合理使用事件处理可以创建高性能的用户界面

通过这个渐进式的学习过程,你现在应该对 JavaScript 事件和 React 合成事件有了全面的理解。记住,实践是最好的老师,多写代码,多尝试不同的场景,才能真正掌握这些概念。

《很全面的前端面试题》——HTML篇

作者 天天扭码
2025年7月8日 21:15

前言

本文整理了小编学习的一些(其实是大量)的面试题,这里主要是HTML方面的面试题,会对每一个面试题进行详尽的讲解,如果有什么没有提到的HTML方面的常考面试题,欢迎大家补充,共同进步!

都退后我要开始装逼了.gif

一、src 和 href 的区别

点开你的项目中的一个HTML文档,你很可能发现下面的两段代码

<link href="styles.css" rel="stylesheet">
<script src="app.js"></script>

两段代码分别是对外部css和js资源的引用,但是引用的方式却不相同

一个引用时使用href,而另一个则是使用src,那么这是为何呢,明明都是加载外部文件不是吗?

带着这个疑问,我们开启下面的讲解——src与href的区别...


1.下面的这句话是我们讲解的基础

最核心区别:
src 直接嵌入并替换当前元素内容(如JS执行、图片显示),而 href 建立与外部资源的关联关系(如CSS应用、链接跳转)。

本质差异:
src 是  "把资源拿过来用"href 是  "告诉浏览器这个资源和我有关"

我们提取关键词,src是‘嵌入’,href是‘关联’

2.那么何为嵌入?何为关联?

看下面的例子

    <img src="http://gips3.baidu.com/it/u=1022347589,1106887837&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280" alt="" width="100px" height="100px">
    <a href="http://gips3.baidu.com/it/u=1022347589,1106887837&fm=3028&app=3028&f=JPEG&fmt=auto?w=960&h=1280">点击下载图片</a>

image.png

这里的图片直接显示在页面上,这就是‘嵌入’

这里的一个超链接,不会将图片直接显示在页面上,这就叫‘关联’

这里只是举个例子方便大家理解,实际上‘嵌入’和‘关联’不只是展示和不展示的关系,还有更深层的关系

我们再次回到在文章开篇给大家的例子,css和js的引用

3.为什么css就是关联,而js就得嵌入?

我们来研究一下css,css只是修饰 HTML,不改变文档结构,只影响渲染样式。

而js呢,动态修改 DOM、执行逻辑,可能改变文档结构或行为。

一个不会影响HTML的文档结构,而另一个会影响,这就是为什么css是关联,而js是嵌入,不嵌入如何影响文档结构呢?不影响文档结构为什么要嵌入呢

4.性能上的区别

当然上述只是较为浅显的讲解了src和href的直观的区别,其实src与href还有诸如性能上的区别

下面我们基于下面的表来讲解

对比项 src href
加载方式 同步加载(默认阻塞渲染) 异步加载(不阻塞 DOM 解析)
执行/渲染 立即生效(如执行 JS、显示图片) 按需生效(如点击链接、应用 CSS)
缓存策略 强缓存优先(如 Cache-Control 协商缓存优先(如 ETag
错误影响 可能导致元素失效(如图片裂图) 通常降级处理(如 CSS 加载失败)

(1)加载方式的区别

src 的加载特点

src 属性用于嵌入式资源,浏览器会同步加载这些资源。这意味着:

<script src="app.js"></script>
<img src="photo.jpg">

当浏览器解析到这些标签时:

  1. 暂停 HTML 解析
  2. 立即下载 app.js 或 photo.jpg
  3. 执行/渲染资源内容
  4. 完成后才继续解析后续 HTML

这种同步加载机制可能导致页面渲染延迟(特别是当脚本很大时)。

href 的加载特点

href 属性用于关联性资源,浏览器会异步加载这些资源:

<link href="styles.css" rel="stylesheet">
<a href="about.html">关于我们</a>

加载过程:

  1. 继续解析 HTML(不阻塞)
  2. 后台并行下载 styles.css
  3. 按需应用(CSS 下载完成后才渲染样式)

例外情况:CSS 会阻塞渲染(避免"无样式内容闪烁"),但仍异步加载。

(2)执行/渲染时机的区别

src 资源的执行

src 资源的生效是立即且强制性的

<!-- 图片会立即加载并显示 -->
<img src="banner.jpg" alt="广告横幅">

<!-- JS 会立即下载并执行 -->
<script src="analytics.js"></script>

如果资源加载失败:

  • 图片会显示破损图标
  • JS 会抛出错误并中断执行

href 资源的应用

href 资源的生效是按需或延迟的

<!-- CSS 下载完成后才会应用样式 -->
<link href="theme.css" rel="stylesheet">

<!-- 只有点击链接才会加载页面 -->
<a href="contact.html">联系我们</a>

如果资源加载失败:

  • CSS 会降级使用浏览器默认样式
  • 链接点击会跳转到错误页面

(33)缓存策略的区别

src 资源的缓存

浏览器对 src 资源倾向于使用强缓存

<!-- 图片可能被长期缓存 -->
<img src="logo.png" alt="公司标志">

缓存表现:

  • 优先使用 Cache-Control: max-age
  • 适合不变资源(如图片、静态JS)

href 资源的缓存

href 资源更多使用协商缓存

<!-- CSS 经常使用ETag验证 -->
<link href="styles.css" rel="stylesheet">

缓存表现:

  • 优先使用 ETag/Last-Modified
  • 适合频繁更新的资源(如CSS)

(4)错误处理的区别

src 资源错误

加载失败会直接影响功能:

<!-- 图片加载失败会显示alt文本 -->
<img src="missing.jpg" alt="图片缺失提示">

<!-- JS加载失败会中断后续脚本 -->
<script src="missing.js"></script>

href 资源错误

加载失败通常有优雅降级:

<!-- CSS加载失败会使用浏览器默认样式 -->
<link href="missing.css" rel="stylesheet">

<!-- 链接失效会显示404页面 -->
<a href="deleted.html">已删除的页面</a>

二、对HTML语义化标签的理解

对于这个问题,确实是面试官经常问到的,而且回答比较固定,就按照下面的解读顺序来解答

1. 定义语义化标签

"语义化标签是指HTML中那些具有明确含义和用途的标签,它们不仅能告诉浏览器如何显示内容,更重要的是能清晰地表达内容的含义和结构。"

2. 语义化标签的优势

"使用语义化标签主要有以下几个好处:

  • 更好的可读性:代码更易于理解和维护
  • 更佳的SEO:搜索引擎能更好地理解页面内容
  • 更强的可访问性:辅助技术(如屏幕阅读器)能更准确地解读页面
  • 更清晰的文档结构:使页面层次更加分明"

3. 常见语义化标签举例

"HTML5引入了一些重要的语义化标签,比如:

  • <header>表示页眉或内容区块的头部
  • <nav>定义导航链接
  • <main>表示文档的主要内容
  • <article>表示独立的内容区块
  • <section>定义文档中的节或段
  • <aside>表示与周围内容相关但不直接相关的内容
  • <footer>表示页脚或内容区块的底部"

4. 与传统div布局的对比

"相比传统的div+class命名方式,语义化标签:

  • 使HTML结构更直观
  • 减少了不必要的class命名
  • 提供了更丰富的文档结构信息"

5. 实际应用建议

"在实际开发中,我通常会:

  1. 优先使用合适的语义化标签
  2. 只在没有合适语义标签时使用div
  3. 结合ARIA属性进一步增强可访问性
  4. 确保标签的嵌套关系合理"

如果面试官表现出兴趣,可以进一步深入展示自己的知识面:

沙雕表情 (2).gif

(1)历史背景

"在HTML5之前,开发者主要依赖div和span配合class来构建页面,这导致了所谓的'div汤'问题。HTML5引入语义化标签正是为了解决这个问题。"

(2)技术细节

"语义化标签不仅影响文档结构,还与DOM API和CSS选择器有很好的配合。例如,我们可以直接使用document.querySelector('main')来获取主要内容区域。"

(3)性能考量

"虽然语义化标签本身对性能影响不大,但它们可以带来更好的代码组织和维护性,间接影响开发效率和长期项目性能。"

(4)实际案例

"在我之前参与的项目中,我们通过重构使用语义化标签,使得页面在搜索引擎中的排名提升了约15%,同时屏幕阅读器用户的满意度也有显著提高。"

三、<script>标签中的Async 与Defer 的区别

script 标签的 defer 和 async 属性,是为解决传统脚本加载阻塞 HTML 解析的问题而生传统方式中,脚本加载执行会暂停 HTML 解析,易致白屏或 DOM 操作错误。 defer 让脚本并行加载,等 HTML 解析完按顺序执行,适合需操作 DOM 或有依赖的脚本;async 使脚本并行加载,完成后立即执行,顺序不定,适合独立脚本。二者提升了加载效率,优化了用户体验。

HTML 中,<script>标签的deferasync属性都用于控制脚本的加载和执行时机,但它们有以下区别:

  1. 加载方式
  • defer:脚本会与 HTML 并行加载,但会等到 HTML 解析完成后(DOMContentLoaded 事件触发前)才执行
  • async:脚本会与 HTML 并行加载,加载完成后立即执行,可能会打断 HTML 的解析
  1. 执行顺序
  • defer:多个带defer的脚本会按照它们在 HTML 中出现的顺序执行
  • async:多个带async的脚本执行顺序不确定,先加载完成的先执行
  1. 适用场景
  • defer:适用于需要在 DOM 加载完成后执行,但顺序很重要的脚本,如操作 DOM 的库或框架。

  • async:适用于独立的、不依赖其他脚本和 DOM 的脚本,如广告、分析脚本。

下面是一个简单的示例,展示它们的区别:

    <!DOCTYPE html>
    <html>
    <head>
        <!-- 1. 正常脚本:立即加载并执行,阻塞HTML解析 -->
        <script src="script1.js"></script>
        
        <!-- 2. defer脚本:并行加载,按顺序执行,HTML解析完成后 -->
        <script defer src="script2.js"></script>
        
        <!-- 3. async脚本:并行加载,加载完成立即执行,顺序不确定 -->
        <script async src="script3.js"></script>
    </head>
    <body>
        <!-- 页面内容 -->
    </body>
    </html>

执行流程大致为:

script1.js 会阻塞 HTML 解析 script2.js 和 script3.js 会并行加载 script3.js 可能在 HTML 解析完成前执行(如果加载快) script2.js 会在 HTML 解析完成后,按顺序执行(无论何时加载完成)

image.png

四、谈谈web worker

给大家推荐一篇关于web worker的文章www.ruanyifeng.com/blog/2018/0…

Web Worker 是 HTML5 引入的一项浏览器 API,它允许 JavaScript 在后台线程中运行复杂计算,避免阻塞主线程(UI 渲染线程),从而显著提升页面响应速度和用户体验。

我们通过一个最简单的Web Worker案例,去体验一下Web Worker是如何运行的

下面是一个最简单的 Web Worker 示例,包括主脚本和 worker 脚本两部分

主脚本 (main.js)

首先,在你的 HTML 文件中引入主脚本:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Web Worker Demo</title>
</head>
<body>
    <h1>Web Worker Example</h1>
    <script src="main.js"></script>
</body>
</html>

然后,创建 main.js 文件,并添加以下代码:

// 这段代码首先检查当前浏览器是否支持 Web Workers。`Worker` 是创建 Web Worker 的构造函数。
// 如果浏览器不支持 Web Workers,则会显示一条消息给用户。
if (typeof(Worker) !== "undefined") {
    //通过 `new Worker("worker.js")` 创建一个新的 Web Worker 实例,
    //这里 `"worker.js"` 是指向 Worker 脚本的路径。
    //这意味着将要运行的后台脚本位于名为 `worker.js` 的文件中。
    var worker = new Worker("worker.js");

    // 当接收到消息时触发
    worker.onmessage = function(event) {
        console.log("从 Worker 接收到的消息: " + event.data);
    };

    // 向 Worker 发送消息
    worker.postMessage("Hello Worker!");
} else {
    console.log("抱歉,你的浏览器不支持 Web Workers...");
}
Worker 脚本 (worker.js)

接着,创建一个名为 worker.js 的文件,并添加以下代码:

// 当接收到消息时触发
onmessage = function(event) {
    console.log("从主线程接收到的消息: " + event.data);

    // 处理完数据后,发送消息回主线程
    postMessage("你好,主线程!已收到你的消息: " + event.data);
};

具体的代码解释,我以注释的方式放在了代码之中,值得注意的一点是接收消息是onmessage、发送消息是postMessage这是内置的函数,不要随意修改

上述的代码就完成了worker.js中的内容会在独立的线程中运行,不会影响主线程,而且可以和主线程进行通讯

当然,我们也可以和面试官聊一聊web worker的缺点

  1. 性能开销:创建新线程需要额外资源,初始化成本较高,对于轻量级任务反而可能降低效率。
  2. 通信瓶颈:线程间数据传递需通过序列化 / 反序列化(深拷贝)实现,频繁通信会导致显著延迟。
  3. 功能受限:无法直接操作 DOM 或访问主线程的 windowdocument 对象,应用场景受限。
  4. 调试复杂:多线程环境下的调试难度较大,错误堆栈追踪和问题定位更具挑战性。
  5. 内存管理:Worker 线程不会自动销毁,需手动调用 terminate(),否则可能导致内存泄漏。

如果想深入了解web worker在项目中的使用,可以看我之前的一篇文章从零带你解构GitHub爆火项目——前端实现类谷歌翻译功能<( ̄︶ ̄)↗[GO!]

Suggestion.gif

五、HTML5的离线储存如何使用?

再推荐一篇文章 HTML5-离线缓存(Application Cache)

这个已经被弃用了,不知道会不会有面试官问

六、iframe 的优缺点

<iframe> 是 HTML 中用于在当前页面嵌入另一个网页的标签,它创建了一个独立的浏览上下文(browsing context),允许在父页面中显示外部资源。

该标签有广泛的应用场景
下面讲解三个<iframe>最主要的特点

1. 内容隔离性

iframe 创建了一个独立的浏览上下文,内部的 DOM、CSS 和 JavaScript 与父页面完全隔离,避免样式冲突和脚本干扰。

<iframe src="https://third-party.com/widget"></iframe>

无论第三方内容如何渲染,都不会影响父页面的布局或功能。

2. 跨域加载能力

iframe 可直接加载不同域名的资源,突破同源策略限制(需目标网站允许)。

<iframe src="https://maps.google.com/maps"></iframe> <!-- 加载Google地图 -->

无需复杂的 CORS 配置,即可嵌入第三方服务。

3. 独立生命周期

iframe 内的页面有自己的加载和渲染流程,不依赖父页面。
父页面加载完成后,iframe 仍可异步加载内容:

<body>
  <iframe src="heavy-content.html"></iframe> <!-- 不阻塞父页面渲染 -->
</body>

iframe 的优点

1.iframe 的核心优势在于内容隔离和跨域集成。 通过创建独立的浏览上下文,它能将第三方内容(如地图、广告)与主页面完全隔离,避免样式冲突和脚本干扰
例如,嵌入 Google 地图只需一行代码:<iframe src="https://maps.google.com"></iframe>,无需复杂的 API 对接。

2.此外,iframe 支持加载不同域名的资源,突破同源策略限制,适合快速整合外部服务。其独立的生命周期也允许异步加载内容,不阻塞主页面渲染,提升用户体验。

iframe 的缺点

1.iframe 的主要代价在于性能开销和开发复杂性。 每个 iframe 都需独立加载资源并占用额外内存,多层嵌套会导致加载速度显著下降。
2.与主页面通信需通过 postMessage,且需严格验证消息来源以防止 XSS 攻击。
3.响应式设计也颇具挑战,需手动计算高度或依赖 JavaScript 动态调整。
此外,搜索引擎通常不会索引 iframe 内容,影响 SEO。安全方面,若未正确配置 sandbox 属性,嵌入不可信来源可能引入安全风险。

因此,现代前端更倾向于使用组件化框架(如 React/Vue)替代 iframe 实现内部功能复用。

七、你认为label标签如何使用?

<label> 标签在 HTML 中用于为表单元素(如 inputselecttextarea)创建关联文本,主要作用是增强表单的可访问性和用户体验

一、基础用法:显式关联

通过 for 属性与表单元素的 id 绑定,点击标签时会聚焦到对应元素。

<label for="username">用户名:</label>
<input type="text" id="username" name="username">

二、隐式关联:嵌套表单元素

将表单元素直接放在 <label> 内,无需 for 和 id

<label>
  记住我
  <input type="checkbox" name="remember">
</label>

三、增强交互体验

  1. 扩大点击区域:用户点击标签文本时,也能触发表单元素。

    <label for="subscribe">
      <input type="checkbox" id="subscribe">
      订阅 newsletter
    </label>
    
  2. 提升无障碍性:屏幕阅读器会朗读标签文本,帮助视力障碍用户理解表单用途。

其实面试官最想听到的就是第三点,增强交互体验,这是label区别于div,span等标签的重要一点

鹦鹉兄弟摇饮料.gif

八、Canvas和SVG的区别

Canvas 和 SVG 都是 HTML5 中用于绘制图形的技术,但核心逻辑和适用场景差异很大。

Canvas 是基于像素的位图绘制技术,通过 JavaScript 动态操作像素,优势在于处理高频更新的动态场景,比如游戏动画、实时数据可视化(像股票 K 线的实时刷新),因为它直接操作像素,绘制大量元素时性能更优;但缺点是放大后可能模糊,且需要手动计算点击区域来实现交互

SVG 则是基于 XML 的矢量图形,用标签描述图形的几何信息,优势是缩放时不会失真,适合静态或交互简单的场景,比如图标、流程图、地图(像点击某个区域高亮),而且原生支持 DOM 事件,交互实现更简单;但如果图形复杂(比如包含上万节点),会因为 DOM 节点过多影响性能。

实际开发中会根据场景选择:需要高性能动态效果选 Canvas,需要无损缩放或简单交互选 SVG,有时也会结合使用,比如用 Canvas 做底层渲染、SVG 做上层交互层。

九、介绍一下img的srcset属性

img标签的srcset属性主要用于解决不同设备(如手机、平板、桌面端)在屏幕尺寸、分辨率上的差异,实现图片的自适应加载,提升页面性能和用户体验

它的核心作用是让浏览器根据当前设备的条件(比如屏幕宽度、像素密度),从开发者提供的多个图片版本中自动选择最合适的一张加载。使用时需要指定多个图片资源及其对应的适配条件,格式是 “图片路径 + 空格 + 条件描述”,多个资源用逗号分隔。

下面举一个例子方便大家理解

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>srcset 属性示例</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      max-width: 1200px;
      margin: 0 auto;
      padding: 20px;
    }
    .image-container {
      margin: 20px 0;
      text-align: center;
    }
    img {
      max-width: 100%;
      height: auto;
      border-radius: 8px;
      box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
    }
    .info {
      margin-top: 10px;
      color: #666;
      font-size: 14px;
    }
  </style>
</head>
<body>
  <h1>img srcset 属性示例</h1>
  
  <div class="image-container">
    <!-- srcset + sizes 组合使用 -->
    <img 
      src="https://picsum.photos/800/400"  <!-- 兼容不支持srcset的浏览器 -->
      srcset="
        https://picsum.photos/400/200 400w,
        https://picsum.photos/800/400 800w,
        https://picsum.photos/1200/600 1200w,
        https://picsum.photos/1600/800 1600w
      "
      sizes="
        (max-width: 600px) 100vw,   <!-- 屏幕宽度≤600px时,图片宽度为视口宽度 -->
        (max-width: 1000px) 50vw,  <!-- 屏幕宽度≤1000px时,图片宽度为视口宽度的50% -->
        800px                       <!-- 其他情况,图片宽度为800px -->
      "
      alt="风景图示例"
    >
    <div class="info">
      此图片会根据设备屏幕尺寸和像素密度自动选择最合适的版本加载。
      <br>
      当前加载的是:<span id="loadedImage"></span>
    </div>
  </div>

  <script>
    // 显示当前加载的图片信息(仅用于演示)
    document.addEventListener('DOMContentLoaded', () => {
      const img = document.querySelector('img');
      const info = document.getElementById('loadedImage');
      
      // 图片加载完成后
      img.onload = () => {
        info.textContent = `${img.naturalWidth}×${img.naturalHeight}px`;
      };
      
      // 窗口大小变化时重新检查
      window.addEventListener('resize', () => {
        setTimeout(() => {
          info.textContent = `${img.naturalWidth}×${img.naturalHeight}px`;
        }, 300);
      });
    });
  </script>
</body>
</html>

这个属性的优势在于避免了 “一刀切” 加载同一张图(比如在小屏幕加载过大图片浪费带宽,或在高清屏加载低清图导致模糊),既优化了加载速度,又保证了显示效果。

magic.gif

十、介绍一下HTML5 drag API

HTML5 Drag and Drop API 提供了一套原生的拖放功能,允许用户在网页中拖动元素并将其放置到指定区域。

先给大家一个例子,大家可以先玩一玩

<!DOCTYPE html>
<html>
<head>
  <style>
    body {
      font-family: 'Arial', sans-serif;
      background-color: #f5f7fa;
      padding: 20px;
    }
    
    .board {
      display: flex;
      gap: 15px;
      margin-top: 20px;
    }
    
    .column {
      background-color: #ebecf0;
      border-radius: 8px;
      padding: 12px;
      width: 250px;
      min-height: 400px;
    }
    
    .column-title {
      font-weight: bold;
      padding: 8px;
      margin-bottom: 10px;
      color: #172b4d;
    }
    
    .task {
      background-color: white;
      border-radius: 6px;
      padding: 12px;
      margin-bottom: 10px;
      box-shadow: 0 1px 2px rgba(0,0,0,0.1);
      cursor: grab;
      transition: transform 0.1s, box-shadow 0.2s;
    }
    
    .task:active {
      cursor: grabbing;
    }
    
    .task.dragging {
      opacity: 0.5;
      transform: scale(1.02);
      box-shadow: 0 4px 8px rgba(0,0,0,0.15);
    }
    
    .column.drop-zone {
      background-color: #e1e4e8;
    }
  </style>
</head>
<body>
  <h2>任务看板</h2>
  <div class="board">
    <div class="column" id="todo">
      <div class="column-title">待处理</div>
      <div class="task" draggable="true" data-task-id="1">设计登录页面</div>
      <div class="task" draggable="true" data-task-id="2">编写API文档</div>
    </div>
    
    <div class="column" id="progress">
      <div class="column-title">进行中</div>
      <div class="task" draggable="true" data-task-id="3">用户注册功能开发</div>
    </div>
    
    <div class="column" id="done">
      <div class="column-title">已完成</div>
      <div class="task" draggable="true" data-task-id="4">首页UI设计</div>
    </div>
  </div>

  <script>
    document.addEventListener('DOMContentLoaded', () => {
      // 获取所有可拖动任务和列
      const tasks = document.querySelectorAll('.task');
      const columns = document.querySelectorAll('.column');
      
      // 当前被拖动的任务
      let draggedTask = null;
      
      // 为每个任务添加拖动事件
      tasks.forEach(task => {
        task.addEventListener('dragstart', () => {
          draggedTask = task;
          setTimeout(() => {
            task.classList.add('dragging');
          }, 0);
        });
        
        task.addEventListener('dragend', () => {
          task.classList.remove('dragging');
        });
      });
      
      // 为每个列添加放置事件
      columns.forEach(column => {
        column.addEventListener('dragover', e => {
          e.preventDefault();
          column.classList.add('drop-zone');
        });
        
        column.addEventListener('dragleave', () => {
          column.classList.remove('drop-zone');
        });
        
        column.addEventListener('drop', e => {
          e.preventDefault();
          column.classList.remove('drop-zone');
          
          // 如果目标列中有其他任务,可以插入到特定位置
          // 这里简单追加到列末尾
          if (draggedTask) {
            column.appendChild(draggedTask);
            
            // 在实际应用中,这里可以发送AJAX请求更新服务器状态
            console.log(`任务 ${draggedTask.dataset.taskId} 移动到 ${column.id} 列`);
          }
        });
      });
    });
  </script>
</body>
</html>

screen_recording_2025-07-08_20-10-21.gif

下面给大家简单的讲解一下这些API的使用

1. 让元素可以拖动

首先,告诉浏览器哪些东西能拖动:

<div draggable="true">把我拖走</div>

加上 draggable="true" 这个元素就能被拖动了

2. 设置拖放区域

然后,指定哪些地方能接收被拖动的元素:

<div id="drop-area">拖到这里放下</div>

3. 添加4个关键事件

(1) 拖动开始时(dragstart)
document.querySelector('[draggable]').addEventListener('dragstart', function(e) {
  e.dataTransfer.setData('text/plain', '随便带点数据'); // 像快递一样带上数据
  this.style.opacity = '0.5'; // 拖动时变半透明
});
(2) 拖到目标上方时(dragover)
document.getElementById('drop-area').addEventListener('dragover', function(e) {
  e.preventDefault(); // 必须写这句才能放下
  this.style.background = 'lightblue'; // 高亮显示
});
(3) 离开目标区域时(dragleave)
document.getElementById('drop-area').addEventListener('dragleave', function() {
  this.style.background = ''; // 取消高亮
});
(4) 放下时(drop)
document.getElementById('drop-area').addEventListener('drop', function(e) {
  e.preventDefault();
  const data = e.dataTransfer.getData('text/plain'); // 取出之前带的数据
  this.innerHTML = `收到了:${data}`; // 显示收到的内容
  this.style.background = ''; // 取消高亮
});

十一、<!Doctype html>有何作用?

<!DOCTYPE html> 是 HTML5 文档的文档类型声明(Document Type Declaration),它是 HTML 文档的第一行代码,用于告诉浏览器当前文档使用的是 HTML5 标准。

面试过程中,你可能会遇到如下的问题

1. 基本作用

  • 声明文档类型:明确告诉浏览器这是一个 HTML5 文档,使用最新的 HTML 规范解析。
  • 触发标准模式(Standards Mode):确保浏览器按照现代 Web 标准渲染页面,避免旧浏览器的兼容性问题。

2. 为什么必须放在第一行?

  • 避免浏览器进入混杂模式(Quirks Mode)
    如果 <!DOCTYPE html> 缺失或不在文档开头,浏览器可能会以“混杂模式”渲染页面,导致样式和布局表现异常(如盒模型计算错误)。
  • 确保文档解析正确
    HTML 解析器需要先读取 DOCTYPE 来决定如何解析后续内容。

4. 对浏览器渲染模式的影响

  • 标准模式(Standards Mode)

    • 启用条件:存在正确的 <!DOCTYPE html>
    • 特性:严格遵循 W3C 标准,正确解析 CSS 和 JavaScript。
    • 示例:width: 100px 仅包含内容宽度(不包含 paddingborder)。
  • 混杂模式|怪异模式(Quirks Mode)

    • 启用条件:缺失或错误的 DOCTYPE。
    • 特性:模拟旧浏览器(如 IE5)的渲染行为,可能导致布局错乱。
    • 示例:width: 100px 包含内容 + padding + border(类似 box-sizing: border-box)。

5. 实际开发中的意义

  1. 确保跨浏览器一致性
    现代框架(如 React/Vue)的模板默认包含 <!DOCTYPE html>,避免因浏览器差异导致布局问题。

  2. 避免兼容性问题
    例如,在混杂模式下:

    • marginpadding 的计算可能异常。
    • 某些 CSS3 属性(如 flexbox)可能失效。
  3. 验证工具依赖
    W3C 验证器等工具需要 DOCTYPE 来判断文档是否符合标准。


6. 如何验证当前模式?

通过 JavaScript 检测:

if (document.compatMode === "CSS1Compat") {
  console.log("标准模式");
} else {
  console.log("混杂模式");
}

十二、meta viewport 有什么作用,怎么写?

<meta name="viewport"> 是移动端网页开发的核心标签,用于控制网页在移动设备上的显示方式和缩放行为。

注意:如果只写 <meta name="viewport"> 而不指定 content 属性,该标签将完全无效,浏览器会直接忽略它。

一般的写法如下

<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">

具体的参数意义如下——

参数 作用 示例
width device-width 视口宽度=设备宽度 width=375(iPhone6/7/8)
initial-scale 数字 初始缩放比例 1.0(不缩放)
minimum-scale 数字 最小缩放比例 0.5(可缩小至50%)
maximum-scale 数字 最大缩放比例 2.0(可放大至200%)
user-scalable yes/no 是否允许用户缩放 no(禁止缩放)

十三、浏览器乱码的原因是什么?如何解决?

乱码的主要原因

  1. 字符编码声明错误或缺失

    • 网页未指定编码或指定了错误的编码
    • 服务器发送的HTTP头与页面声明的编码不一致
  2. 文件实际编码与声明编码不匹配

    • 文件保存的编码(如UTF-8)与HTML中声明的编码(如GBK)不同
  3. 浏览器自动检测编码失败

    • 浏览器自动识别功能可能误判编码类型
  4. 字体支持问题

    • 浏览器缺少显示特定字符所需的字体

解决方案

1. 正确声明HTML文档编码

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">  <!-- 推荐使用UTF-8编码 -->
    <!-- 或者传统的声明方式 -->
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
</head>
<body>
    <!-- 页面内容 -->
</body>
</html>

2. 确保文件实际编码与声明一致

  • 使用代码编辑器(如VS Code)检查并转换文件编码
  • 保存文件时选择正确的编码格式(推荐UTF-8)

3. 配置服务器HTTP头

确保服务器发送正确的Content-Type头:

Content-Type: text/html; charset=utf-8

4. 检查字体支持

  • 在CSS中指定备用字体
body {
    font-family: "Microsoft YaHei", Arial, sans-serif;
}

十四、介绍一下渐进增强与优雅降级

渐进增强:先保证所有设备能看(基础HTML),再给新设备加特效(CSS/JS)
优雅降级:先做出最酷效果(最新技术),再让旧设备凑合能用

下面举例子让大家了解一下两者的开发流程

1. 渐进增强(Progressive Enhancement)

核心思想:先保证基础功能可用(HTML),再逐步增强(CSS → JS)。
适用场景:内容型网站(博客、新闻站)、对可访问性要求高的项目。

① HTML 基础结构(所有设备都能访问)

<!-- 基础链接 - 即使没有JS也能跳转 -->
<a href="/details.html" class="enhanced-link">查看详情</a>

② 添加 CSS 增强(提升视觉体验)

/* 基础样式(所有浏览器) */
.enhanced-link {
  color: blue;
  text-decoration: underline;
}

/* 增强样式(支持CSS3的浏览器) */
@supports (display: flex) {
  .enhanced-link {
    padding: 8px 12px;
    background: #f0f0f0;
  }
}

③ 用 JavaScript 增强交互(现代浏览器才生效)

// 检测浏览器是否支持JS和Fetch API
if ('fetch' in window) {
  const link = document.querySelector('.enhanced-link');
  link.addEventListener('click', (e) => {
    e.preventDefault(); // 阻止默认跳转
    fetch('/api/details').then(...); // AJAX加载内容
  });
}

✅ 优点

  • 低端设备(如旧手机、屏幕阅读器)至少能看内容
  • 代码更健壮,不容易完全崩溃

2. 优雅降级(Graceful Degradation)

核心思想:先按最新技术开发完整功能,再兼容旧浏览器。
适用场景:Web 应用(如在线工具)、内部系统(可控浏览器环境)。

实现方法

① 默认使用最新技术(如 CSS Grid、AJAX)

<button id="ajax-button">加载数据</button>
<div id="content"></div>
// 默认用Fetch API
document.getElementById('ajax-button').addEventListener('click', () => {
  fetch('/api/data').then(response => response.json()).then(updateUI);
});

② 检测兼容性并降级

// 如果不支持Fetch,改用XMLHttpRequest或直接跳转
if (!('fetch' in window)) {
  document.getElementById('ajax-button').onclick = () => {
    window.location.href = '/legacy-data.html'; // 降级方案
  };
}

CSS 降级示例(用 @supports 检测)

/* 现代浏览器:用CSS Grid */
.container {
  display: grid;
  grid-template-columns: 1fr 1fr;
}

/* 旧浏览器降级为Flexbox */
@supports not (display: grid) {
  .container {
    display: flex;
    flex-wrap: wrap;
  }
  .container > * {
    width: 50%;
  }
}

✅ 优点

  • 现代用户获得最佳体验
  • 开发效率高(先实现理想效果,再处理兼容)

十五、介绍一下HTML5的新特性

这是一个很宽泛的问题,面试官只是想考察我们对HTML发展的掌握,我认为应该挑其中的重点来回答,比较重要的包括下面的五部分
大部分前文都有介绍,部分没介绍的会放到之后的文章介绍,比如存储API会放在面试浏览器篇介绍

1.语义化标签

<header> <nav> <article> <section> <footer> <aside> <main>
  • 替代<div>,提升可访问性和SEO

2.媒体支持

  • 原生音视频:<audio> <video>(不再依赖Flash)
  • 画布绘图:<canvas> + JavaScript API

3.表单增强

  • 新输入类型:email url date range color
  • 属性:placeholder required pattern autofocus

4.存储API

  • localStorage/sessionStorage(替代Cookie)
  • IndexedDB(结构化数据存储)

5.Web Workers

  • 多线程计算,避免阻塞UI

十六、b与strong的区别

我们可以从以下的几个角度回答

1.视觉表现

两者默认都显示为加粗文本

2.语义差异

<b>:仅表示视觉上的加粗,没有语义含义;<strong>:表示内容具有重要性或紧急性,带有语义强调

3.使用场景

<b>:当只需要视觉上加粗,不需要特别强调内容重要性时;用<strong>:当内容确实重要,需要特别强调时(如警告、关键信息)

4.对辅助设备的影响

屏幕阅读器会以不同语调朗读<strong>的内容;<b>对辅助设备没有特殊处理

★★★但是,我们的回答不要止步于此,碰到了自己擅长的领域必须好好的‘秀秀肌肉’

Suggestion.gif

1.<i> vs <em>

<i>:仅表示视觉斜体(如图标、术语);<em>:表示需要强调的文本(语义斜体)

2.<s> vs <del>

<s>:仅表示视觉删除线(如促销价);<del>:表示文档中被删除的内容(有语义)

3.<u> vs <ins>

<u>:仅表示视觉下划线;<ins>:表示文档中新增的内容

结语

HTML 面试题看似基础,但往往藏着不少细节,掌握它们不仅能让你在面试中游刃有余,还能在日常开发中写出更优雅的代码。希望这篇整理能帮你查漏补缺,信心满满地迎接面试挑战!

记住,面试官也是从新手过来的,放轻松,展现你的实力就好!

祝你面试顺利,offer 拿到手软! 如果有其他问题,欢迎在评论区交流~

Suggestion.gif

我犯了错,我于是为我的uni-app项目引入环境标志

作者 168169
2025年7月8日 21:03

前情

最近公司在规划一个全新项目,但是又对项目前景有些怀疑,于是想做一个项目获客验证的运营活动,就是为了决定后续项目可行性和投入规模。

注:时间都宝贵,如果不想浪费时间看一些无聊的事情原委的,只想了解环境标志是怎么回事的,可直接跳到实现环境标志段即可

测试正式难区分

小程序的发包流程的,在HBuilder X上点击发行-小程序就会自动打生产包,同时唤起小程序IDE,再通过IDE点点测测后没啥问题就再通过小程序IDE的上传版本上传代码包到小程序后台,此时可以查看体验版,体验版码给到测试验收没啥问题后,再走提审流程,审核通过后就可以发到线上

我负责的前一个小程序因为因为测试和生产的配置数据相差比较大,一眼就能区分当前是在测试还是在生产,这样在上传包的时候就能轻松区分当前接口是否是在生产环境,我现在负责的小程序首页只是一个入口页,和生产是一模一样的,我平时上传包的时候是看一下目前运行的代码目录来区分是在开发还是在生产的

image.png

一坑又一坑

项目其实在上线前2天就已经测试差不太多,但卡在一个前置条件,我们活动需要依赖支付宝的芝麻免押,又因为我们的项目流程因各种原因被支付宝打回,所以项目流程一直在调整,后面为了申请免押,直到芝麻免押过审一直反复提了4版,最后一版是产品完全推倒重新做的需求稿,我在开发这一版的时候我重新牵了一个新分支,因为过审后这一版是直接废掉的,因为老板一直在催,我于加急调整需求,在开发到80%的时候,产品突然找到我说芝麻免押过审了,我现在手上的需求可以搁置,让我重新回滚到正常的那一版(掉坑1)。

支付宝小程序和别的小程序不一样,它是按迭代来做小程序版本管理的,在反复的提版中我因为有一版刚刚走完了提审迭代到了待发布状态,此时产品又提了修改点,我于是就废掉了这一迭代,调整需求后重新新提了一个迭代,走了提审,提审后,我看着迭代列表中刚刚那个没走完的迭代一直那么显眼,我看着它总感觉不爽,于是我就删除了它(掉坑2)

此次活动主入口是在抖音小程序,在抖音上下单后,通过短信携带链接发送给用户,用户点击短信唤起支付宝小程序完成余下芝麻免押流程,在走通测试后,服务端做了下正式服商品配置,因为此次活动较紧急没有做配置后台,于是服务端把测试服商品调整为了和正式库一样的配置,方便到时上线直接更新(掉坑3)

被怼

因抖音小程序活动的测试数据被服务端同步和正式库数据成了一样,当我在做最后发版验收的时候,怎么都支付不了,我和服务端二个都自查了代码,也连调了好一会也没有找到问题原因,最后还是找的抖音客服才知道是因为测试库对支付是有金额限制的,不能大太,只能是小金额,具体经过详见我的博文:抖音小程序支付错误码141211

祸不单行,在做支付宝发包的时候,不知道因为什么原因,导致支付宝小程序IDE没法自动迭代,具体经过可以查看我的博文:支付宝小程序IDE版本迭代异常

直到晚上22点多二个问题都还没有解决,而此时老板一直在问进度,说今晚一定要上线……

抖音小程序因为线上是可以支付,我们于是说先不管测试支付不了的问题,先提生产版,等审核过了直接去线上验证,有问题直接回滚

支付宝因为前面提了几版用于申请芝麻免押能力,而正式上线是要去掉这些,产品让我回滚代码到正常版,而此时我理解的是上面新拉的调整分支不用,直接回到主分支再提一版即可,其实这里是错的,因为主分支也加了一些小修改用于申请芝麻免押能力。

还有个致命问题就是支付宝小程序IDE无法上传版本,找了支付宝技术人员,他们也没有找到问题,在我多次要求能不能先不管问题,先通过别的方式先绕过这个问题先提版本再说,支付宝技术人员教我手动建迭代试试,此时已经晚上11点了,我急忙忙的去手动新建迭代,此时IDE可以下拉选择到新建迭代了,也就临时绕过问题可以发包了,同时向支付宝技术人员要求能不能走个加急审核,支付宝说可以,此时我一心只想尽快提审版本,于是乎就提了审,几分钟内就过了审,我也点了发包,终于松了一口气

但是没过几分钟产品跑过来说回退的版本是错的,带了芝麻免押审核的一些调整,又过了一会,测试又跑过来说发的版本有问题一直报订单不存在,我当时就蒙了,因为我只是切了下分支,主分支原先就是验证通过的,直到BOSS跑过来说,线上的商品价格怎么有1块的,是不是发错环境了,我此时意识到问题了,我在上传版本的时候因为紧急忘了确认当前是生产还是测试了,因当时电脑上测试和生产同时跑着,我一急就点错了,BOSS此时很不耐烦的说,你写小程序也有这么久了,怎么还会出现这种问题,一会抖音支付不了,一会又包错包,我此时尴尬到了极点,我于是加紧了提了一版,幸运提此次提版审核也十来分钟就过了,顺利的发了包

此时已经晚上11.30分,过了晚上11点就没有地铁了,我打的的士,在的士上我反复在想为什么会犯这种低级错,生产和测试包都能提错,我想到平时自己发包都是通过看代码路径来判断是否是生产和测试的,如果不急的情况下,是不会有问题的,但是事情一紧急就会忘记去确认代码路径,我此时想到应该给项目引入环境标志,一眼就能看出当前是什么环境,避免因手忙脚乱导致发错环境。

引入环境标志

环境标示-日志

想到的第一种方式是通过打印日志,在日志中标志出当前服务端地址,打印普通的日志不是十分显眼的,很容易被淹没,我于是选择打印一个定制化的彩色日志,效果如下:

image 1.png

关键代码如下:

import { BASE_URL } from '@/config/http';

/**
 * 环境日志打印
 */
export const initEnvLog = () => {
  uni.$off('envlog');
  uni.$on('envlog', () => {
    console.log(`%c--- 项目环境 ----:%c${process.env.NODE_ENV} ${BASE_URL.split('//')[1]}`, 'color:white;background:blue;padding:6px;', 'color:red;background:white;padding:6px;');
  })
}

任何地方只要调用uni.$emit(’envlog’)即可在控制台打印环境日志

环境标志-页面标示

为什么做了日志环境标示还要做页面标示,为了开发调试需求一般都会在控制台打印非常多的日志,像我现在的项目,为了能快速发现服务端接口问题,我在封装通用接口请求的时候,默认会把请求信息和接口响应信息全打印

image 2.png

还有一些别的开发日志,控制台是很丰富,虽然日志标志已经做成鹤立鸡群,一眼可以看出,但还是有很大可能日志会被淹没,于是想到做一个页面标志

image 3.png

我把它封装成组件并全局注册,在所有页面都使用此组件即可,为了不影响测试测试,同时为该标志增加了拖动功能,可以手动依靠在页面任一位置,同时还把项目彩蛋页入口也加到上面,如果你快速点击按钮6次+,就会让你选择是隐藏标志,还是跳彩蛋页,有了页面标志,就算你一时急,没有查看日志。页面上一个大大的测试服是逃不掉你我们眼镜的,组件代码如下:

<template>
    <image 
        class="env-log" 
        v-if="isShow"
        @click="handleClick" 
        @touchstart="handleTouchStart"
        @touchmove="handleTouchMove"
        @touchend="handleTouchEnd"
        :style="{ transform: `translate(${position.x}px, ${position.y}px)` }"
        src="data:image/webp;base64,UklGRi4YAABXRUJQVlA4WAoAAAAQAAAA8QAAwAAAQUxQSEoNAAAB8Eds23fF//8dkixJkiVJkiRJkiRJkjHGSEZGxnj8SMbIw0jGSEYyJPP8NZJkZGRkJMnIGGMkSZIkScZIkiRJlmVZlmVJkuOP+7iu+64/rvu/p4iYAPy/p5EXK/HUZm+YajqmXAlNkYlbGp+FpKZjWibyw1Bk8pbWIyGo5ZQ+T0NPZIr+H4Sc0t+03Puu5sNNY4Lm67dZ3eo41HRf07xbCRSq8zAzSnNmAN4DEQ8v2V9p3iyDnBbp0FK4S/MojO/ETVipOqcx8wTmcRELKaUZGs/rYDkjVkNKOY1bhbBdFBMhBYYZ2H8WvWHlUPX4iF6SHEFYHVPpUjtU9IxUQna+KAsdOBBc92F+vEtyNCyUTberBsWBIB5vUzaFguKPJKcExhSrfOUv0ngQAqKTV/QuZHmyDtSun+YLWg4536sUjdMe1Cm+s3tP63SR21Vs0vahB6OKDRZlu/T53umeZmh9kevJ2leH2YaGOP3uudw0/U57UKs4oTozNKbHFAudrfw3/bd4MKLY4mm6pXG1GLvqmas1p2jervulziKerH11lgtEY9SplwBeqBlHe0pz6iUQPRac8qBGcQbYpF4rAYBISpy52T80rxQDQNWVYIsHw4qPkRS3w9CTgpUuNkbzf6H71UnEg10VL1z2JB/C2Kr6XOzC9B7mNcFJUa34rWqf/F0GyyvxxcV2Dcuw/KTY4sEbxT9Q1gqgc3r9pdgQ+y7WYbhtN5Tt0Xic7cGWShUDwBhJblcDGBPXLoYRxUSJeJah5bgoywiuAYhQr/XmPRSsdjEsKW4CKFygfbMHLxR7ABhI/lDPnCz3QHEUXTH6PBL4ruIFQMzCOOZkKEsoLtMyLfhvEY0LTgFl276+uxnaDZY7VR2KjR48UawDMOYn3lvrZBjyMwhgUB0KzKptAGi7sPNuf+xtjLgWVqw2K+FdEXwv8s4FewAg8vLAjzxf/djf6lLRS1OmH7o0LVjvQZuK50M++BGA3Kp1J7SbymB+pfYFJgSnFFD632CYKHcnjBl6TKUrikMCh4J1BiAg7jgUttWp4Z8MzRWiXm1bXAV06lJ1ir2e/B+0XRcYFuwxpUXiy6GPFZfCR5WoBBrPad8rcCLieYak2Ady294snJg6XaCkvvS+FMQFT6ID9JsqEn8IThniYh06/+HQt7Pkp0o4YH2cZOZsb+5ZwZ2hTzFB85XigsBvwToVE98MLrlE8+3WcP0dYV9ZzhW/VXwknqhddSlmHeTQwtt/R00+9hqArH11nuvBjmCPuBITDrLk4+KO8MVqFN7qG8Ep0arieQDyKd85Re6TAQBVGTu23FE0bUo9gh5WbPBgTXAKQI166hI1MXIGQNGbfZt03h3hjeGoAsasffU7y9OobmuBx+I24hJHJDkNb/V/d87VcyCafydZB+onLKtvBEc8+C64C7wUq3DJmIdTHhmtbWuvQvFEJvPuLtCu+NwCI4pVnirFToyKAaeYEhw3ecs/0fv2LvBVJaMW2FObHoyrL/goKpwia0twwqJm4Zby7E5KrwV/2lTfCPZ58uIiheIEySO4ZcGp4ITKGadl1l1gRLHbAmMqXQwALwWbUPnr7HOZY6AyJTjhqT6k5SXuNOdMJQosck4FlzzYF6/gpu23ghPA4DUtD5vuBu2KixZoU3ziaRKzjoJ/FCfXaTuMO59S7LLAF3Xowbyny1Uwrqw3y3H3kWMVL7QoTAg2e4qvmHoNd/3h5/QZ7mWj4rwF/lLTHtQ2wmUju1apoRzc0zHFDgusi0SWx3HrT22+RXF/99VlvkWFYJX7vLmmbaL1HlUpfrHAoGcCrluwQp83A/cHbxSrLfBk4Ws3XLflkv4Xc+4NttS/bVz4HS3jJu6X3puyjDh3qeJNWs7lt14YmHx+X/BS8IE7VcVoTnQByJ83kN8L7gmWRb8z1SdpXo5CdiUNjHfek2jC0+dKLRkaM30wF60ZyPmCe4Ennl5HenhN4245rAeuDIy9uBeYJ1ONbtR1Q+NkFnxW/zaQv5ruQ/5obxRO3EPj9TP4z/5gIhdK786ZX9EYb0CgrRcmXo1HQ8Jvw24xfHZufPoDAAoWTeTV+4JQMKYWs2GfPUGSffA+OjaRyeHcEJC37hmAz9IdemMCGEiZyPhcf7PrATMbc+3w2ZGizlMo/Gghf828anS6AD/QXGoAqlft5M70izoHKhiZ+b788U1n7p2UbtP8EtadR77kvOvUxKnTk5XB1cRpfg6/T1eD4EPHmaTt+6DqEzT/gQArJ1L+vjjOqhVrgmlM0jyEYCMv9vysOM6g3etAWtM0XxUHBKB59syqwzEKn9X6wIbVuyAeXtF2MzgAeS3/jE0v/NzY2OiBW7bf8CzfB4p6FpNqJxJAJ31O3IWzlmRI/vTjLap/3DfSC6DxdW+hXcp0Jdht6uOvETdqp3c4AGPBZ5KJVqtVddn4VF1VqUmSHHOiCsGHQXVc0nsWsWkUW0XAiODvCIDcH5QRF8K/RaIkkII5Gj/Y4ClPx1vg/So4A2RtUC7APWtqAsCRh7tBdFzSss4GFTBGdgWfY5a63TWi00mSizW+GgSnfRXM0XrXyrY0LTJT1Mdwy8jbFOVRlh+MCf7hI/eQPgeCwV/CctAt/jijud9X1i9xXWu3Sr/pkmDw3e4w4hJN+7RN5PlB9Y2HJ7k27bT8JvgzoIJLm0wV3LHiG33+1xdGBGdsXhtuF2vwb8GnweCRTQecMXv4mn5vSnzhl2CnxZxarAGAQ3GZH0xRwvQBzljziwEu+Ku+FsliU4cn1gbZLDhrkV9nyj+k8TuccZi2I0UVe4KNppbB7ggAvBFcNeFFklvFMI4LPjI0pLhVKCJbNH6DK9bs0XK7GkCb2jI0kEy3AcCG4IAJqIVl5ESc54nGFMlpMUnjElxxhJaZfsgfgk/VN5K8jAIoywjWWNi3Cn7y1KdIMuZpofFbliPkrtNyvQS68lacZottD9cAoFcdZQeDacF2AGeUACInhkU4YtEBzYkeWE4LvhZDgiMA8F1wMqDcC3EWQT/lHIAp6nk4YvUFzYtR2EYzIpHnwa64bQFQEBd8GAweCk5GYqoVAPU0HLElTeP1X/A5IjguKq48vCwE8EjFcoPBrOAi5SEA7IkPcMSKKxrjDfCbcyFuyjzoF1wDgBnB6YDyzoXxtafd8w6uuEfjr2L47xX8KrAlOAIg90ywKRi02BV48PSIb+CKwzQu5SDI34LNoiwjblsANN+K4+xgMGozD+eso/Etgm1XOwIvBM/zAHwQfB8Qti0euMeQYRhBLws+E1gWnAGQFxOsC6g0ZbiAe+acqIOsoGpuxbGKpgQfAOhV+1nBoNsw5iBoV3wdFD4L/iHQrS7yAewLDgaEeVXuIphT6WhQ0Yw4VvgsOAugSV2XBZR37tmEkxYmBWeDwqhgt8o9EXwA4IvgRkBoIpl55CZ4qVgXVG5MHCjU34iLfCCaEXwZEEb4uRiuuqV2bboTidmoAf2CnQqDgrMAhlSyMCAUwV2rbgR7TRUkeZBlyL4UewZsCHYA2WeCU0E57XsVzzMMethnwGvBx4bipLgsADoVq90r50RwwtAgknmGSFxsGdAp2AxgU624F9rVbbXCiocfDBgUfGDAJ89NDoAGxQ4Hqls5SjJ2sL70adCDecE1Q6tguSE3LtZNkSUy8RjeJXXkPh20XfBEk4JdClviqwFDgi0GoLABulpxwHlWrNgNAP+osxz1SLDJkJsUqxa2n1WqwHUu7RY92BYcUdgTewa8FWwKokRxynXm7L6I2ltxXaq6BHsNuUnxMwiMq9tqx6mKWdUJ/Ffwq8KBuIwojAjWB1GYFvzpOCj6wkzi/PjXzsZGH3TkXPCJ+kNw1JCXFt+CwLCKuU6wj1W8SOBEZIoURgVrA3h1rOIuhhnBH+ql4LihIC3G/ETexmncd7LIkeALgUvBYoX3YtjH83OazyqdDDUqUy4G1EdDYcZTYzdDy4NiOHqf4JaIxAXLFPrSZC+sS2i5UwBnXxIc8uCtmjEg0pAH+waLD3D4/DPBOk9eSrDc4D8/rs5a4PTN6siDMfUlMDz1pCYjcPJqE4YExz2FGcGqwNC69KYeTl717zNuVRiwJtgKAJNqIThXb1yiN1luKEqI83wAFYo1oSI6T+MrAx4KzgHAktoaeN5eWxQSOhM0z5rwb8EuAC3KGD9YmxnsqnG7blommy2wI5LFAPbszOdrn2pdrYGWp1WwLUt5uAygOxiSp/mOVm+xH4V9l0gDwHlQHHI0XKj4K/ie9vzwvA5s1tWeey5fR+A/ckDGqz156aBKXQ1to4NtCLbk3WoN5AcV39jY2Ng7iavEA4TZUhWDMVLZ9sdgOcLtouBzQyiuU5vhpWE5GWvxgU3B/NASI3nd6KNLtYWVXHp3fOBEvAordWLXT7/oDitdYttPJE3yMiusvBGbfjBMrlYirE6LNV+ob0B4XRWr/kLtiVgOVTfie5gqo1wKU23qY5jqUf+EqXeqNUzNqkiYWhfnCNOnYjlUxcR/Q9WW+CtUPfesFoYqvD361Y3/JxVWUDggvgoAAFA4AJ0BKvIAwQA+MRiJQyIhoRVbXQggAwSxN3Bgd/zPGs/DX6O55XsB+s2wj49/IvwA/QDxZvwA/VX/AdAB+AH6AWn/luf4B+AF4VB99A/IjwmMrc8/Gn9sP9z8otQfpX9f/Of9i/5v+e+AH6V+ZvHH6S+sV5Z+J/5L+M/tF/bP///3fu7/Jf1E/YD5F+YH+g/+O/ln7O/5n///976Nv2S93vmG/R3/mf3T/v/+75b/55+y3qd+xb/M/4h/0P3l/+3wN///3Uv1J9in9KP/v7MP8y/9H+m/9P//+jD9Vf+l/kvgf/pf8i/7f73f//5AP3/6wD9////8Q/AD6JfxP7/DPoJK+9076grnhfhlnM3xE3Tyx30WSWaNVs0+JoJK+9vE8ovUigKlfHzZaTGyKaMvyoATM5K+PvAELPy6zNHsq3yfgxQTtgAbnflHGeuaIJDJPAFJVDnrUCzTVAUeI8DBaAFidLXs2+Xnp2er0rUS4E2iQv4caUL42G3hAQoWhjSuXr+HGdjDx98jUnWgky4ev4caVNiQY4Sen4ujX4is/c0m74TosyzJ6J0OJKzDWpr3WZMVj2seQJVj7WPIEqxOgxeizwbTF6LHAAD+u6qBAPadY2ptc3Pq2zOhFr43r0jg/SBlVve02M5T98pg1f+EIZMxJ6zlTE/VrCIoQGzV+inEwZ0ohe/IpDQeBWXGQuApf/uNk79Jq1LdKtCDpy42kv5rvQiMP6ESxp7gnrJuXbUKSO9tuYg72+5pjaP3iZKi/m1+uu3aP4a98Qq81RB8xpqORBxCgLNahbrT24YStbVo0nG3fUvNIghT53ZXJOOw6irxt/90B4F2jopHplLus3ghAFaqQF0/EuxRySMR1ao0l9+bMwvlIreRzTrMmQVZjyFHbaS14ki0VSCIapeTaBiqGJomY5WCn/7ktGpIs5NonL+VBusoOiDvbbsnUXCo+P3b46dNmsUvj2d3eSWL6AJGi+7/wvemfkaN2x1hCfhsRwuAe5okyyQBQq0Bq78u3HNqOLrKZvZj5q4iJd4Eh74OgNPcsd2YrlltdbK4s/zWaPAC26cwH9qfdXoQfOE7IxQffTLErq6EsuST2ZzD4m8kfeCjmkF7mK6966ujUmalyt0t+LybQq///tx2ThwGMb3qACp3g+udrxxJxp235I23kmqj8TJJ/BbBnuV7bqTINH8oo4LduS3yplDwusZvWOzODiJvKOdMxcELm7RdyGcnCpwXK194g0740L74Ty2m41yWv2E1KYvBqt13Fw+gPBPClaVGeBEQtRsmejIH2a+le3ut8LvR+Z+JtZNK6wsHepWR2o7hRXm9ddRpXcJogqVs+Gd32agEPsxcFNK/5oklgGn//7Z8os6T9AhkOR/sCwDy6QnEfu/Cj2Svw4CIP8ZQeHe+Ogv9sMtBErELLuGsdUbDjPEGG4/D1+FKououmLjZu7oh+W0u+eIMlnxrWIDevdT83tf1jn/lxA4Cg8KL/hPgwxsjsySn51sKwwy2mvvsXXSRLCjSkTGv/R0LiEKw//+bXKfN+9ZNFA/BOVGPI96BBB3OfhbnjRoeoj5+Inl8nG3Itc+Wff5SKQKy2jPnojZhT4og/Q7FjbdwNOtwPZvaAd6hcNA+qEiD8IUdYhOg0e4kga7/1Fr1JUPXUoPatNLuXz/G3m8LHcUr4NDTxT9V3leYmUbu/pGnQSQDWsAoRprbZd1LbjCiP1l9YCnlPjcpJD6cMl+l7JUZioKyCHb4vTOabm1DjU+Hf1lmiCcm7Mkh41CGC3C6mx7cX/8gErfAqVYODsQk9ewdGrqdSAzm/cdd1m8c7/b5BXqaVndn4qR1g80BY7FrfwUjTGgZC5JmfyPH55gzYvcI276DhXcgxsqxkxyxDjhS9+jB1++InzbxLM7MznQ8BVuk9vtm7+fYgrwn7xALGU6gXbPaZf1Tr+oJC4tXHT52h+ABz1swwxKPCniiCBP+NY82JO7yfV1dOlAytC0Nmq5cuOammN10Niu+YqoQ534iGKfuRhYuAL0l3WM5mnGOptNE3QkR7fHaEgloBTqwxL0X7xBu8zjyHdSzoQiRbMcpFUc8hpfWNOh/564dOokzhlmLbC/jHG3wC0Z6U0H/Br3Jprbhvgm3yVg4VI9T//1XLeckRBnktZDyfcXeIujfN46+1wozj3TcnWHcG7sq4/QABDNXYBc1hSFylJt+YAlKEe62jbqJv7ciE0NZFxHFUKa6FQRY5i3lHev2s7iyNS5oFVDzAd4vgNf+aoBZfXtOZRXt04kkc9XiIOgb/f/9IWlKm5bViP5H0tP2qyteZFkFOP85EJJQjMMBHJwEbuHOj3zh6FcPT9+PEUaZZsW4PayhBZiN2F1ACpDWaggf4Pca4PmrTPDGGC5hzWWZod4kx0uTtzhS7QlK/3sbU+YGv9PLqkQLu8rqdBTvNtYExqkOwBxQpBVH+M2OYaJAHA2XmRYW9rnj0feMmx0WLQ/c72+Rw5JuzNE/Z4oyZjhue0DynqDXBsU/fPIIeQZpil1CNJqR5duUDwHClbZ9q5/GtFdASEqjb6X80Ad2NfErWgU0tScXG4Uq+JXoARUoJf6dhP4eU1+/J3x/9mE8MsmRzlJxSkzwquxlsx6MX8jIWsXYCrtReCG63SacM39jmnWE9p/V50oEpS1heVGmRgG+YQeS0zP8f/lQ98cvSkwCEwSmgDuhjAGp9iSduFHwbDenyMXZpbQQqeeWqxiaN97IqZmwGtX7MEEferiwV31usF5a2KbAT2r3KNwEiy/mm41v3ycG6SgGrvCAo/GFvFMmGiuKmD88O4bBcRUq5S/X+9siQAVT4mMPgArCyrJQY0GZiuBpctzKivC9NOgTcO8179/HUBYarAhcwUR3Vs1a/47jeoSVmHWxx4SaUNSMZmc9ueaKC85V3G1gP/7Xh+3Nb4tbGOitFOm4sOgV83uOejQs3wf89/6t0ybrU8oBQcBGJ6goW26A/U8RrWpBKPWHclDYyiaythGnUg/y2IKrDg/eaB0aVH0fzeKlIFjoSBpW2wPpAoIHJvsxe35ve9TZ9nCPnRSEWT2YN3qkpHnoNaj0IOLGLuCpofdaiwFwrPOzs+Nhkja8iVrVYnLWiMn0mkCof/hOhkGf1yg0HdQ8XJ1E7cO9oKc8QdHBQJBbOGrvIhd3wHV8qg++E79B4+cPAAAAQcB9Nl4EWwF9ILCdFdyQ69AqycNludJ03BFYMgErpmvNRjCuAUx81Svzv2ye0Z9h6p1aOjw6htYuCRwUDLKCy6Vqb4thJAriuveyHDfPZWpfQPBYMNQhedXWgjbdopjPLrsE/gBPmfhlnPQhxMztOyt2/riKvO4j3bpAWUwdBVwZaNuQoYvfDGOUKhSMd+eBokiwDooaQQMvVUI9q6d1xY72A6dPM9Z5v8znq4vDWYU8txpGTod2dV17GGrFUbATjCg4Dw7AmbPXOt0Z5fbkmFDbQ1puxWlONpzcBmil42NXWImIE9U0PfjmnSgNhxOXBxCkb2fYuJahLThdghKl11O/hH+TQ5IXXIRZ5IaZh8f//917sCYsBzJL/rqPavJ6XpkAjrNyF2b2Jq4as2DHpR7oNkRJD1goABEyUu0bfY+SDsLP8kVWsQo8Zm2II14bRQeX2wAAAAAAAAAA" 
        mode="aspectFit" 
    />
</template>

<script setup>
    import { ref, onMounted, nextTick } from 'vue';

  const clickCount = ref(0);
  const isShow = ref(false);

    // 拖拽相关状态
    const isDragging = ref(false);
    const startTouch = ref({ x: 0, y: 0 });
    const position = ref({ x: 0, y: 0 });
    const startPosition = ref({ x: 0, y: 0 });

  // 连续点击6次隐藏环境标志
  let timer = null;
    const handleClick = () => {
        // 如果是拖拽状态,不处理点击事件
        if (isDragging.value) return;

        clickCount.value += 1;
    clearTimeout(timer);
    timer = setTimeout(() => {
      clickCount.value = 0;
    }, 1000);
        if (clickCount.value > 6) {
      uni.showModal({
        title: '',
        content: '请选择操作',
        cancelText: '隐藏标志',
        confirmText: '彩蛋',
        success: (res) => {
          if (res.confirm) {
            uni.navigateTo({
              url: '/other/egg/egg'
            })
          } else {
            isShow.value = false;
          }
        }
      })
        }
    }

    // 触摸开始
    const handleTouchStart = (e) => {
        e.preventDefault();
        isDragging.value = false;
        const touch = e.touches[0];
        startTouch.value = {
            x: touch.clientX,
            y: touch.clientY
        };
        startPosition.value = { ...position.value };
    }

            // 触摸移动
    const handleTouchMove = (e) => {
        e.preventDefault();
        if (!startTouch.value) return;

        const touch = e.touches[0];
        const deltaX = touch.clientX - startTouch.value.x;
        const deltaY = touch.clientY - startTouch.value.y;

        // 如果移动距离超过5px,认为是拖拽
        if (Math.abs(deltaX) > 5 || Math.abs(deltaY) > 5) {
            isDragging.value = true;
        }

        if (isDragging.value) {
            const newX = startPosition.value.x + deltaX;
            const newY = startPosition.value.y + deltaY;

            // 获取窗口尺寸进行边界限制
            const systemInfo = uni.getSystemInfoSync();
            const screenWidth = systemInfo.screenWidth;
            const windowHeight = systemInfo.windowHeight; // 使用窗口高度而不是屏幕高度
            const imageWidth = 121; // rpx转px大约是屏幕宽度的1/750 * 121
            const imageHeight = 96;
            const realImageWidth = (screenWidth / 750) * imageWidth;
            const realImageHeight = (screenWidth / 750) * imageHeight;

            // 边界限制
            const maxX = screenWidth - realImageWidth;
            const maxY = windowHeight - realImageHeight;

            position.value = {
                x: Math.max(0, Math.min(newX, maxX)),
                y: Math.max(0, Math.min(newY, maxY))
            };
        }
    }

    // 触摸结束
    const handleTouchEnd = (e) => {
        e.preventDefault();
        startTouch.value = { x: 0, y: 0 };

        // 延迟重置拖拽状态,避免触发点击事件
        setTimeout(() => {
            isDragging.value = false;
        }, 100);
    }

  onMounted(() => {
    if ((process && process.env && process.env.NODE_ENV === 'development') || import.meta.env.MODE === 'development') {
      isShow.value = true;

            // 初始化位置到右下角
            nextTick(() => {
                const systemInfo = uni.getSystemInfoSync();
                const screenWidth = systemInfo.screenWidth;
                const windowHeight = systemInfo.windowHeight; // 使用窗口高度而不是屏幕高度
                const imageWidth = 121;
                const imageHeight = 96;
                const realImageWidth = (screenWidth / 750) * imageWidth;
                const realImageHeight = (screenWidth / 750) * imageHeight;

                position.value = {
                    x: screenWidth - realImageWidth,
                    y: windowHeight - realImageHeight - 100 // 距离底部100px,基于窗口高度
                };
            });
    } else {
      isShow.value = false;
    }
  })

</script>

<style lang="scss">
.env-log {
    width: 121rpx;
    height: 96rpx;
  position: fixed;
  top: 0;
  left: 0;
  opacity: 0.65;
  z-index: 9999;
    transition: none; // 拖拽时不要过渡动画
    user-select: none; // 防止选中
}
</style>

引入的环境标志效果如下:

1751867074382-20250707_115657.gif

期望

有的人一定会说为了一个判断环境特意封一个组件,有点杀鸡用牛刀,解决问题的方法有千千万,能解决你当下问题的方法,就是好的方法

我期望的做法的,我想后续封装一个vite插件,通过插件注入日志组件,生产环境直接丢掉,这样不会为生产带去多余的代码同时也是一劳永逸的事,不像现在还要手动插入

如果你有更棒的解决方案,欢迎你留言分享,一起学习进步

从一个实战项目,看懂 `new DataTransfer()` 的三大妙用

作者 ErpanOmer
2025年7月8日 19:16

最近,我写了一份文件上传组件的代码(Jquery😄,先别喷)。但在前端开发中,处理<input type="file">一直是个麻烦事,主要是因为它的files属性是只读的,我们没法用JavaScript去直接修改用户选择的文件列表。

这导致很多常见的需求,比如“带删除按钮的预览列表”,实现起来都非常别扭。 如下图:

image.png

而这个组件,只用了一个我们平时不太注意的API——new DataTransfer(),就优雅地解决了这个问题。

这篇文章,我们就来逐行分析这组件代码,从中提炼出几个能直接用在项目里的技巧。


new DataTransfer(),实现对input.files的“写”操作

我们先来看这个组件里的最核心的函数:

// 代码片段 1: 核心函数
function setInputFiles(fileList) {
  // 步骤 1: 创建一个空的 DataTransfer 对象
  const dataTransfer = new DataTransfer();

  // 步骤 2: 遍历你自己的文件数组,把文件添加到这个对象里
  for (let file of fileList) {
    dataTransfer.items.add(file);
  }

  // 步骤 3: 把这个对象的 .files 属性,赋值给 input 元素
  document.getElementById('custom-upload-input').files = dataTransfer.files;
}

这段代码揭示了解决问题的关键: 虽然input.files本身是只读的,我们不能对它进行pushsplice操作,但浏览器允许我们用一个新的FileList对象去整个替换它

new DataTransfer()构造函数,就是那个能帮我们凭空创建出所需FileList的工具。

这就是我们需要掌握的,也是最重要的技巧:通过创建一个DataTransfer实例并填充它,我们可以间接地实现对<input type="file">文件列表的程序化控制。


构建“可控的”自定义预览列表

我们再来看组件中的其他部分,它是如何构建出一个带“删除”功能的预览列表的。

// 代码片段 2: 全局变量与删除逻辑
const files = new Map(); // 用一个Map来管理我们自己的文件状态

// ... (addFileToList 函数中)
$item.find('.custom-upload-remove').on('click', function() {
  $item.remove();
  files.delete(id); // 1. 从我们自己的Map中删除文件

  // 2. 如果文件都删完了,就清空input
  if (files.size === 0) {
    const dataTransfer = new DataTransfer();
    document.getElementById('custom-upload-input').files = dataTransfer.files;
  }
  // 注意:更完整的逻辑应该是在删除后,用Map中剩余的文件去更新input
});

这里的代码非常清晰:

  1. 分离“数据状态”与“DOM状态”:代码没有直接操作input.fisles,而是创建了一个独立的files变量(这里用Map,非常适合通过唯一ID进行增删),作为唯一可信的数据源(Single Source of Truth)。
  2. UI渲染基于自己的数据状态:页面上的文件预览列表,完全是根据files这个Map来渲染的。
  3. 响应用户操作,先更新数据状态:当用户点击“删除”时,代码首先操作的是files.delete(id),更新我们自己维护的数据。
  4. 最后,同步DOM状态:在数据状态更新后,再调用技巧一中的setInputFiles函数(或者一个简化的清空逻辑),用files中最新的文件集合,去覆盖inputfiles属性,保证两者同步。

最后就是:通过“自有状态 -> 更新UI -> 同步input”这个单向数据流,我们可以构建出任何我们想要的、文件上传交互界面。


统一处理多种上传方式,简化逻辑

在这个组件中,同时处理了用户的“点击选择”和“拖拽上传”两种情况。

// 代码片段 3: 事件处理
// 点击选择
$('#custom-upload-input').on('change', function(e) {
  handleFiles(e.target.files);
});

// 拖拽上传
$('#custom-upload-area').on('drop', function(e) {
  e.preventDefault();
  handleFiles(e.originalEvent.dataTransfer.files);
});

这里无论是e.target.files,还是拖拽事件中的e.originalEvent.dataTransfer.files,它们返回的都是一个FileList对象。

DataTransfer这个API,其本职工作就是处理拖拽事件中的数据。event.dataTransfer是浏览器原生提供的实例。而new DataTransfer()允许我们自己创建一个,这恰好为我们统一这两种上传方式提供了可能性。

因此,我们:将文件处理逻辑封装在一个独立的函数(如handleFiles)中,这个函数接收一个FileList对象作为参数。这样,无论是点击上传还是拖拽上传,最终都可以调用这个统一的函数,避免了代码重复,让逻辑更清晰。


完整代码, 可以用在你们项目中供参考:

const files = new Map()
const allowedTypes = [
'image/jpeg','image/png','image/gif','image/jpg','image/webp',
'video/mp4','video/quicktime','video/mov','video/avi','video/mpeg'
];
const maxSize = 20 * 1024 * 1024; // 20MB

function formatSize(size) {
if (size > 1024*1024) return (size/1024/1024).toFixed(1)+'MB';
if (size > 1024) return (size/1024).toFixed(1)+'KB';
return size+'B';
}


$(document).on('submit', `#form`, function (event) {
  event.preventDefault(); // 防止表单提交(为了演示)

  const values = {}
  // 获取表单的所有数据
  for (const { name, value } of $(this).serializeArray()) {
      values[name] = value
  }

  values.media_list = []

  for (const element of files.values()) {
    values.media_list.push(element)
  }

  console.log(values)

  this.reset();

  $('.thankyou').addClass('er-flex').removeClass('er-hidden');
  $(this).hide();

  fetch('https://demo.com/third_part/product_activation', {
     'method': 'POST',
     'headers': {
       'Content-Type': 'application/json'
     },
     body: JSON.stringify(values)
  })
  return false
});

// 点击上传区域,触发文件选择
$('#custom-upload-area').on('click', function(e) {
  // 避免点击input本身时重复触发
  if (e.target.id === 'custom-upload-input') return;
  $('#custom-upload-input').trigger('click');
});

// 选择文件后处理
$('#custom-upload-input').on('change', function(e) {
  handleFiles(e.target.files);
  // 不要在这里再触发 click,否则会死循环
});

$('#custom-upload-area').on('dragover', function(e) {
  e.preventDefault();
  $(this).addClass('dragover');
});
$('#custom-upload-area').on('dragleave', function(e) {
  e.preventDefault();
  $(this).removeClass('dragover');
});
$('#custom-upload-area').on('drop', function(e) {
  e.preventDefault();
  $(this).removeClass('dragover');
  handleFiles(e.originalEvent.dataTransfer.files);
});

function setInputFiles(fileList) {
  const dataTransfer = new DataTransfer();
  for (let file of fileList) {
    dataTransfer.items.add(file);
  }
  document.getElementById('custom-upload-input').files = dataTransfer.files;
}

// 在 handleFiles 里调用
function handleFiles(fileList) {
  let filesArr = [];
  for (let file of fileList) {
    addFileToList(file);
    filesArr.push(file);
  }
  setInputFiles(filesArr);
}

function addFileToList(file) {
  $('#custom-upload-list').find('.custom-upload-error-msg').remove();
  $('#custom-upload-list').find('.error').remove();
  const id = 'file_' + Math.random().toString(36).substr(2,9);
  let error = '';
  if (!allowedTypes.includes(file.type)) {
    error = `${file.name} has invalid extension. Only jpg, jpeg, png, gif, webp, mp4, mov, avi, mpeg allowed.`;
  } else if (file.size > maxSize) {
    error = `${file.name} is too large. Max 20MB allowed.`;
  }

  if (error) {
    return $('#custom-upload-list').append(`<div class="custom-upload-error-msg">${error}</div>`);
  }

  const isImage = file.type.startsWith('image/');
const fileContent = isImage
  ? `<img src="${URL.createObjectURL(file)}" alt="${file.name}" class="custom-upload-thumb"/>`
  : `<video src="${URL.createObjectURL(file)}" alt="${file.name}" class="custom-upload-thumb"></video>`;

  const $item = $(`
    <div class="custom-upload-file${error ? ' error' : ''}" id="${id}">
      <div class="text-size12 er-mb-2 er-break-all">${file.name}</div>
      <div class="er-flex er-items-center">
        ${fileContent}
        <span class="er-flex-1"></span>
        <span class="custom-upload-filesize">${formatSize(file.size)}</span>
        <div class="custom-upload-progress"><div class="custom-upload-progress-bar"></div></div>
        <svg class="custom-upload-remove" xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#d16b8a"><path d="M267.33-120q-27.5 0-47.08-19.58-19.58-19.59-19.58-47.09V-740H160v-66.67h192V-840h256v33.33h192V-740h-40.67v553.33q0 27-19.83 46.84Q719.67-120 692.67-120H267.33Zm425.34-620H267.33v553.33h425.34V-740Zm-328 469.33h66.66v-386h-66.66v386Zm164 0h66.66v-386h-66.66v386ZM267.33-740v553.33V-740Z"/></svg>
      </div>
    </div>
  `);
  $('#custom-upload-list').append($item);

  uploadFile(file, id);
  $item.find('.custom-upload-remove').on('click', function() {
    $item.remove();
    files.delete(id);

    if (files.size === 0) {
      const dataTransfer = new DataTransfer();
      document.getElementById('custom-upload-input').files = dataTransfer.files;
    }
  });
}

function uploadFile(file, id) {
  const $bar = $(`#${id} .custom-upload-progress-bar`);
  const $item = $(`#${id}`);
  // 这里用演示用的模拟上传,实际请替换为你的上传接口
  const fakeUrl = 'https://httpbin.org/post';
  const formData = new FormData();
  formData.append('file', file);
  formData.append('file_from', 'photo-contest');

  $.ajax({
    url: fakeUrl,
    type: 'POST',
    data: formData,
    processData: false,
    contentType: false,
    xhr: function() {
      let xhr = new window.XMLHttpRequest();
      xhr.upload.addEventListener('progress', function(e) {
        if (e.lengthComputable) {
          let percent = (e.loaded / e.total) * 100;
          $bar.css('width', percent + '%');
        }
      }, false);
      return xhr;
    },
    success: function(res) {
      files.set(id, res.url);
      $bar.css('width', '100%');
      $item.removeClass('error');
    },
    error: function() {
      $item.addClass('error');
      $item.html(`<div class="custom-upload-error-msg">${file.name} Upload failed. Please try again.</div>`);
    }
  });
}

好了分析完毕🙂

我们再来回顾一下,从这段代码里,我们能学到关于new DataTransfer()的哪些技巧:

  1. 核心:利用new DataTransfer()来创建一个可控的FileList对象,并将其赋值给input.files,从而实现对这个“只读”列表的程序化“写入”。
  2. UI操作:将文件状态与DOM状态分离。在JavaScript中维护一份自己的文件列表,UI交互都只操作这份自有数据,然后再将这份数据同步回input元素。
  3. 兼容:抽象出统一的文件处理函数,用它来兼容点击、拖拽等多种不同的文件来源,保持代码的整洁和可复用性。

new DataTransfer()确实不是一个我们每天都会用到的API,但它在处理文件上传这个特定场景时,是一个非常有效且规范的解决方案。

希望这次的分析,能让你对这个API有更深入的、更贴近实战的理解。

谢谢大家❀

Vue 3 + Element Plus 实现可定制的动态表格列配置组件

作者 P7Dreamer
2025年7月8日 18:59

引言

在后台管理系统中,表格是最常用的数据展示方式之一。不同的用户或不同的场景下,我们往往需要展示不同的表格列,或者对列的显示方式有不同的需求。本文将介绍如何基于 Vue 3 和 Element Plus 实现一个可定制的动态表格列配置组件,让用户可以自由选择需要显示的列、调整列的顺序、配置列的显示方式等。

组件设计思路

我们的动态表格列配置组件主要由三部分组成:

  1. CustomizeTableColumns - 负责单个表格列的渲染
  2. ColumnConfigDialog - 提供列配置的交互界面
  3. 主页面 - 整合组件并提供数据

这种设计遵循了单一职责原则,每个组件只关注自己的核心功能,使得代码更加清晰、易于维护。

核心组件实现

1. CustomizeTableColumns 组件

<template>
  <el-table-column
    :prop="column.prop"
    :label="column.label"
    :align="column.align || 'center'"
    :width="column.width"
    :min-width="column.minWidth"
    :fixed="column.fixed"
    :sortable="column.sortable"
    :formatter="column.formatter ? dateFormatter : null"
    show-overflow-tooltip
  >
    <!-- 自定义列模板 针对特殊列通过插槽进行特殊处理 -->
    <template #default="scope" v-if="column.slot">
      <slot name="column-cell" :column="column" :row="scope.row" :index="scope.$index"></slot>
    </template>
  </el-table-column>
</template>

<script setup lang="ts">
import { dateFormatter } from '@/utils/formatTime'

const props = defineProps({
  column: {
    type: Object,
    required: true
  }
})
</script>

功能说明

  • 封装了 el-table-column 的基本配置
  • 通过 slot 支持自定义列内容渲染
  • 内置了日期格式化功能

使用示例

<CustomizeTableColumns v-for="col in dynamicColumns" :key="col.prop" :column="col">
  <template #column-cell="{ column, row, index }">
    <!-- 自定义列内容 -->
    <template v-if="column.prop === 'index'">
      {{ index + (queryParams.pageNo - 1) * queryParams.pageSize + 1 }}
    </template>
    <template v-else-if="column.prop === 'typeName'">
      <span
        style="cursor: pointer; color: #409eff"
        v-if="row.id"
        @click="openForm('preview', row.customerName, row.customerId, row.id)"
      >
        {{ row.typeName }}
      </span>
    </template>
  </template>
</CustomizeTableColumns>

2. ColumnConfigDialog 组件

<template>
  <el-dialog
    v-model="showDialog"
    title="表格列配置"
    width="800px"
    append-to-body
    class="column-config-dialog"
  >
    <!-- 对话框内容 -->
  </el-dialog>
</template>

<script setup lang="ts">
import draggable from 'vuedraggable'

const props = defineProps({
  modelValue: Boolean,
  columns: Array,
  defaultColumns: Array
})

const emit = defineEmits(['update:modelValue', 'update:columns'])

// 控制对话框显示
const showDialog = computed({
  get: () => props.modelValue,
  set: (value) => emit('update:modelValue', value)
})

// 临时列配置
const tempColumns = ref([])

// 全选控制
const checkAll = computed({
  get: () => tempColumns.value.every(col => col.visible),
  set: (val) => tempColumns.value.forEach(col => col.visible = val)
})

// 保存配置
const saveColumnConfig = () => {
  const newColumns = tempColumns.value.map((col, index) => ({
    ...col,
    order: col.fixed ? col.order : index
  }))
  emit('update:columns', newColumns)
  showDialog.value = false
}
</script>

功能特点

  1. 列可见性控制:可以勾选显示/隐藏列
  2. 拖拽排序:使用 vuedraggable 实现列顺序调整
  3. 基础配置:可以修改列名、对齐方式、宽度等
  4. 高级配置:支持固定列、最小宽度、排序等高级设置
  5. 全选/重置:一键全选或重置为默认配置

3. 主页面集成

<template>
  <!-- 表格部分 -->
  <el-table :data="list">
    <CustomizeTableColumns v-for="col in dynamicColumns" :key="col.prop" :column="col">
      <!-- 自定义列模板 -->
    </CustomizeTableColumns>
  </el-table>
  
  <!-- 配置对话框 -->
  <ColumnConfigDialog
    v-model="showColumnConfig"
    :columns="columnOptions"
    :default-columns="defaultColumns"
    @update:columns="handleColumnsUpdate"
  />
</template>

<script setup lang="ts">
// 默认列配置
const defaultColumns = ref([])

// 当前列配置
const columnOptions = ref([
  {
    prop: 'index',
    label: '序号',
    width: 80,
    fixed: 'left',
    align: 'center',
    slot: true,
    visible: true,
    order: 0
  },
  { 
     prop: 'customerName', 
     label: '客户名称', 
     width: 150, 
     align: 'center', 
     visible: true, 
     order: 1 
  },
  {
    prop: 'typeName',
    label: '任务名称',
    align: 'center',
    slot: true,
    visible: true,
    sortable: false,
    showAdvanced: true,
    minWidth: 80,
    order: 2
  },
  {
    prop: 'publishUserName',
    label: '发布人',
    width: 150,
    align: 'center',
    visible: true,
    sortable: false,
    showAdvanced: true,
    order: 3
  },
  {
    prop: 'receiveUserName',
    label: '接收人',
    width: 150,
    align: 'center',
    visible: true,
    sortable: false,
    showAdvanced: false,
    order: 4
  },
  {
    prop: 'missionStatus',
    label: '状态',
    width: 150,
    align: 'center',
    slot: true,
    visible: true,
    sortable: false,
    showAdvanced: false,
    order: 5
  },
  {
    prop: 'missionCancelReasonName',
    label: '作废原因',
    width: 150,
    align: 'center',
    visible: true,
    sortable: false,
    showAdvanced: false,
    order: 6
  },
  {
    prop: 'missionRejectReasonName',
    label: '拒绝原因',
    width: 150,
    align: 'center',
    visible: true,
    sortable: false,
    showAdvanced: false,
    order: 7
  },
  {
    prop: 'missionRejectReasonDesc',
    label: '拒绝描述',
    width: 150,
    align: 'center',
    visible: true,
    sortable: false,
    showAdvanced: false,
    order: 8
  },
  {
    prop: 'publishTime',
    label: '发布时间',
    minWidth: 180,
    align: 'center',
    sortable: false,
    showAdvanced: false,
    formatter: true,
    visible: true,
    order: 9
  },
  { 
      prop: 'operation', 
      label: '操作', 
      visible: true, 
      minWidth: 140, 
      fixed: 'right', 
      slot: true 
  }
])

// 动态列(根据配置生成的可见列)
const dynamicColumns = computed(() => {
  return columnOptions.value
    .filter(col => col.visible)
    .sort((a, b) => a.order - b.order)
})

// 处理列更新
const handleColumnsUpdate = (newColumns) => {
  columnOptions.value = newColumns
}
</script>

关键技术点解析

1. 动态列渲染原理

动态列的核心是根据配置动态生成 el-table-column 组件:

const dynamicColumns = computed(() => {
  return columnOptions.value
    .filter(col => col.visible) // 过滤出可见的列
    .sort((a, b) => a.order - b.order) // 按顺序排序
})

2. 列配置的数据结构

每个列配置包含以下属性:

interface ColumnConfig {
  prop: string // 字段名
  label: string // 显示名称
  width?: number // 列宽
  minWidth?: number // 最小宽度
  align?: 'left' | 'center' | 'right' // 对齐方式
  fixed?: 'left' | 'right' // 固定位置
  sortable?: boolean // 是否可排序
  visible: boolean // 是否显示
  order: number // 排序序号
  slot?: boolean // 是否使用插槽
  formatter?: boolean // 是否需要格式化
  showAdvanced?: boolean // 是否显示高级设置
}

最佳实践建议

  1. 性能优化

    • 对于大数据量的表格,使用虚拟滚动
    • 避免在列配置中使用复杂的计算属性
  2. 用户体验

    • 提供配置导入/导出功能
    • 添加配置保存成功的提示
    • 考虑添加撤销/重做功能
  3. 可访问性

    • 为拖拽手柄添加ARIA标签
    • 确保对话框可以通过键盘操作
  4. 错误处理

    • 验证列配置的合法性
    • 提供默认配置回退机制

效果演示

微信图片_2025-07-08_185408_774.png

微信图片_2025-07-08_185413_486.png

总结

本文介绍了一个基于 Vue 3 和 Element Plus 的动态表格列配置组件的完整实现方案。通过将功能拆分为多个组件,我们实现了可维护的表格列配置的动态表格。关键点包括:

  1. 使用 vuedraggable 实现列顺序拖拽调整
  2. 通过计算属性实现动态列过滤和排序
  3. 利用 Vue 的响应式系统实现配置的双向绑定
  4. 提供基础和高阶的列配置选项

这种方案可以轻松集成到各种后台管理系统中,大大提升了表格的灵活性和用户体验。开发者可以根据实际需求进一步扩展功能,如添加列分组、条件格式化等高级特性。

0基础进大厂,第12天:ES6语法基础篇

2025年7月8日 18:52

let、const VS var

  • let声明的变量不存在声明提升,var存在
  • var在全局声明的变量,会成为window的属性,let不会
  • let 不允许重复声明,var可以
  • let 与 {} 会形成块级作用域,var不会

来看几份案例:

案例一:

// 声明提升
console.log(a);
var a = 10;

输出 undefined

案例二:

//用var声明的变量会添加到window对象中,用let声明的变量不会添加到window对象中
let age = 18;
var age1 = 19;
console.log(window.age1);
console.log(window.age);  

image.png

案例三:

image.png

image.png

案例四:

报错:无法在变量声明之前访问该变量

//暂时性死区
let age = 18;
if (1) {
    console.log(age);
    let age = 19;
}

变量的解构、赋值

案例一:

如果在es6之前,我们想给多个变量声明赋值,大多数人会是这样做的:

let a = 1;
let b = 2;
let c = 3;

es6 新增解构,可以这样声明、赋值变量

let [a, b, c] = [1, 2, 3];

遵循结构一致

const arr = [1, [2, 3, [4], 5]]
const [a, [b, c, [d], e]] = arr;

案例二

变量b接收的是一个数组

// const [a, ...b] = [1, 2, 3, 4, 5]
// console.log(a);
// console.log(b);

image.png 同理:

const [a, c, ...b] = [1, 2, 3, 4, 5]
console.log(a);
console.log(c);
console.log(b);

image.png 案例三: let {自定义的变量名:被接收对象里的属性名,...} = 被接收对象

const obj = {
    name: 'zs',
    age: 18,
    like: {
        n: '打篮球'
    }
}
let { name:name, age:age, like: { n:n } } = obj;

如果自定义的变量名和属性名相同的话,有省略写法:

const sex = '男'
const obj = {
    name: 'zs',
    age: 18,
    like: {
        n: '打篮球'
    }
}
let { name, age, like: { n } } = obj;

案例四:

字符串的解构

const [a, b, c, d, e] = 'hello';
console.log(a, b, c, d, e);

获取字符串的长度:

const str = 'hello';
let { length } = str;
console.log(length);

案例五:

函数参数的解构
这份代码看起来是有点多此一举了。

function fn({ x: a, y: b }) {
    return a + b;
}
fn({ x: 1, y: 2 })

那么来看看这份代码:

function fn1(...args) {
    console.log(args);
}
fn1(1, 2, 3, 4, 5)

image.png 设置默认值。
如果不传入参数也会有一个初始值,为了项目某些判断条件的正常执行

function fn(x = 1, y = 1) {
    return x + y;
}
fn(1, 2)

arguments代表函数的参数列表

function fn() {
    console.log(arguments);
}
fn(1, 2, 3, 4, 5)

image.png

变量的嵌入

es6以前写一个简单的"hello xxx"都是通过字符串拼接实现的
es6以后,是通过反引号里用${}内嵌变量实现的

let myname = 'zs';
console.log("hello" + myname);
console.log(`hello ${myname}`);

let of

let myname = 'zs';
for (let x of myname) {
    console.log(x);
}

image.png

数组的拼接

const arr = [1, 2, 3, 4, 5, 6, 5, 1];
let newArr = ['a', 'b', 'c', 'd', 'e'];
newArr = arr.concat(newArr);
console.log(newArr);

输出结果:

image.png .concat()方法相当于以下的解构的解法

const arr = [1, 2, 3, 4, 5, 6, 5, 1];
let newArr = ['a', 'b', 'c', 'd', 'e'];
arr.push(...newArr);
console.log(newArr);

image.png

数组的排序:

默认的sort()方法并不能很好的实现排序效果,因为它是按照ASCII码来确定的,所以需要按照以下的方法来排序。
从大到小

const arr = [1, 2, 3, 4, 5, 6, 5, 1];
arr.sort((a, b) => {
    return b - a;
})
console.log(arr);

image.png 从小到大 (换成b-a)

const arr = [1, 2, 3, 4, 5, 6, 5, 1];
arr.sort((a, b) => {
    return b - a;
})
console.log(arr);

image.png

事件委托与合成事件:前端性能优化的"偷懒"艺术

2025年7月8日 18:52

今天在前端世界里,我发现了两种神奇的"偷懒"技术——事件委托和合成事件。它们不仅能提高性能,还能让代码更优雅,就像魔法一样神奇!让我们一起来看看这些技巧是如何让我们的代码"躺着也能赢"的。

一、事件委托:以一敌百的"分身术"

想象一下,你管理着一个有100名员工的部门。如果每次发通知都要挨个打电话,你肯定会累趴下。聪明人会建个微信群,只发一次通知,所有人就都收到了——这就是事件委托的哲学!

事件委托原理

<ul id="myList">
  <li data-item="123">Item 1</li>
  <li data-item="456">Item 2</li>
  <!-- 更多列表项... -->
</ul>

<script>
  // 只给父元素绑定事件监听
  document.getElementById('myList').addEventListener('click', function(e) {
    // 通过e.target找到实际点击的子元素
    console.log(e.target.innerText);
  });
</script>

三大核心优势

  1. 性能优化:像精明的老板一样,事件委托只绑定一次监听器(父元素),就能管理所有子元素的事件,内存占用大幅降低。
  2. 动态节点支持:当新增员工(节点)时,完全不用重新培训(绑定事件):
document.getElementById('btn').addEventListener('click', function() {
  const newLi = document.createElement('li');
  newLi.textContent = 'item-new';
  document.getElementById('myList').appendChild(newLi); 
  // 无需单独绑定事件!
});

3. 内存管理:避免了事件监听器的"僵尸军团"问题(旧节点移除后监听器仍存在),减少内存泄漏风险。

可以看到效果,我们子元素点击,以及动态添加地节点,都可以触发我们的点击事件:

B1E595BDE5B620182738_converted.gif

事件委托就是利用了,事件三大阶段中的目标阶段和冒泡阶段

image.png

事件的的冒泡模式总是会将事件流向其父元素的,如果父元素监听了相同的事件类型,当子元素事件触发时,那么父元素的事件就会被触发并执行,这样我们就只需要绑定父元素监听器,就可以完成我们的交互

可以看看我的这篇文章: 事件机制与委托:从冒泡捕获到高效编程的奇妙之旅详细地解释了一下三个阶段,以及addEventListener如何利用这三个阶段

二、阻止冒泡:事件的"结界术"

我们遇到了一个经典场景:点击按钮显示菜单,点击页面任意位置关闭菜单。这需要精准控制事件的传播路径:

<div id="toggleBtn">Toggle Menu</div>
<div id="menu">...</div>

<script>
  toggleBtn.addEventListener('click', function(e) {
    e.stopPropagation(); // 建立结界!
    menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
  });

  document.addEventListener('click', function() {
    menu.style.display = 'none'; // 点击页面任意位置关闭
  });

  // 菜单内的链接需要特殊处理
  closeInside.addEventListener('click', function(e) {
    e.stopPropagation(); // 阻止冒泡
    e.preventDefault();  // 阻止默认跳转
    alert('Menu button clicked');
  });
</script>

这里的关键技巧是:

  • stopPropagation() 像魔法结界,阻止事件向外扩散
  • preventDefault() 像取消魔法咒语,阻止元素的默认行为
  • 两者结合,实现了"菜单内部点击不关闭,外部点击才关闭"的精细控制

如果我们不设置e.stopPropagation();我们点击子元素触发事件最终又流向我们的父元素,从而触发menu.style.display = 'none'; 那我们也就不会显示我们的菜单,我们可以注释e.stopPropagation();对比看看效果

这是我们添加了e.stopPropagation();的效果,我们点击空白处会隐藏菜单:

B1E595BDE5B620183819_converted.gif

当我们注释后,由于冒泡,会最终流向父元素,当我们的子元素把菜单的的display:node设置为block而我们的父元素,又设置成了none所以我们的菜单框就一直处于隐藏状态:

可以看到,我们疯狂点击是没有效果的 B1E595BDE5B620184316_converted.gif

在上述提到的文章,也提到了什么是stopPropagation() ,它会阻止冒泡,也就是当我们点击子元素时,会阻止流向父元素,阻止父元素相同事件触发,e.preventDefault(),是阻止默认行为,比如我们点击a标签会默认页面跳转,而e.preventDefault()可以阻止这种类似的默认事件

三、React合成事件:跨次元的"事件虫洞"

当进入React世界,事件处理变得更有趣。React没有直接使用DOM事件,而是创造了合成事件(SyntheticEvent)

function App() {
  const handleClick = (e) => {
    console.log('立即访问', e.type); // 合成事件
    
    setTimeout(() => {
      console.log('延迟访问', e.type); // 小心!这里可能失效
    }, 2000);
  };

  return <button onClick={handleClick}>click</button>;
}

合成事件有三个神奇特性:

  1. 事件委托的极致:所有事件委托到#root容器,类似我们之前的手动委托,但React自动完成。
  2. 跨浏览器兼容:就像万能翻译器,统一了不同浏览器的事件差异。
  3. 事件池机制:最有趣的魔法!React会回收事件对象(像重复使用茶杯):
// 正常访问
console.log(e.type); // ✅ 工作

// 异步访问可能失效
setTimeout(() => console.log(e.type), 0); // ❌ 可能为空!

可以看到是报错的 f9e5a2f1e11cc1e38ed89facf976b8ff.png

2023年重大更新:React团队解除了这个"魔法诅咒",现在异步访问事件也安全了!但了解这段历史,就像知道魔法世界的进化史一样有趣。react16版本前就不支持

四、事件系统的"四维空间法则"

深入事件系统,我发现它遵循着精妙的规则:

  1. 事件传播三维度

    • 捕获阶段(父→子):像渔网撒下
    • 目标阶段(命中目标):网中捉鱼
    • 冒泡阶段(子→父):收网回拉
  2. 监听器四要素

    element.addEventListener(
      'click',       // 事件类型
      handler,       // 回调函数
      { capture: true } // 捕获选项
    );
    
  3. 性能优化双刃剑

    • 太多事件监听器 → 内存泄漏沼泽
    • 过度事件委托 → 事件判断逻辑复杂化
    • 平衡点:像调咖啡,找到浓度刚好的黄金比例

结语:事件的哲学

今天的学习让我领悟到:前端事件处理就像中国园林设计——最妙之处在于"借景"。事件委托借父元素之力管理子元素,合成事件借虚拟层统一物理层,阻止冒泡则是精准控制能量的流动方向。

当你下次写addEventListener时,不妨想想:我是在创建又一个监听器奴隶,还是在建立精巧的事件生态系统?毕竟,在前端世界里,最高级的勤奋往往是学会"偷懒"——用最少的代码做最多的事!

React合成事件揭秘:高效事件处理的幕后机制

作者 Dream耀
2025年7月8日 18:49

React 合成事件机制解析:为什么它比原生事件更强大?

引言

在现代前端开发中,事件处理是构建交互式应用的核心。React 作为目前最流行的前端框架之一,实现了一套独特的事件系统——合成事件(SyntheticEvent)。这套系统不仅解决了跨浏览器兼容性问题,还提供了性能优化和更便捷的开发体验。本文将深入探讨 React 合成事件的原理、优势和使用技巧。

什么是合成事件?

React 的合成事件是对原生 DOM 事件的跨浏览器包装器。它拥有与原生事件相同的接口,包括 stopPropagation() 和 preventDefault() 等方法,但行为更加一致,且在不同浏览器中表现相同。

jsx

function handleClick(event) {
  // 这里的event不是原生事件,而是React的合成事件
  console.log(event); // SyntheticEvent
  event.preventDefault(); // 跨浏览器兼容
}

<button onClick={handleClick}>点击我</button>

合成事件的核心原理

1. 事件委托机制

React 并没有将事件处理器直接绑定到具体的 DOM 节点上,而是采用了事件委托的模式,将所有事件统一委托到最外层的 #root 容器(React 17 之前是 document):

jsx

// React 17之前
document.addEventListener('click', dispatchInteractiveEvent);

// React 17及之后
rootNode.addEventListener('click', dispatchInteractiveEvent);

这种设计带来了显著的性能优势:

  • 内存占用更低:不需要为每个元素单独绑定事件
  • 动态内容处理:即使是后来添加的子组件也能自动获得事件处理能力
  • 统一管理:方便 React 对事件进行统一处理和优化

2. 自动绑定与上下文

在类组件中,React 的事件处理函数需要手动绑定 this,但在函数组件中,这一困扰不复存在:

jsx

class OldComponent extends React.Component {
  handleClick() {
    // 需要.bind(this)或使用箭头函数
    console.log(this.props);
  }
}

function ModernComponent() {
  const handleClick = () => {
    // 自动绑定正确的上下文
    console.log('无需担心this问题');
  };
}

3. 事件池机制(Event Pooling)

React 16 及之前版本实现了事件池机制,合成事件对象会被放入池中重用,以减少垃圾回收的压力:

jsx

function App() {
  const handleClick = (e) => {
    console.log(e.nativeEvent)// 原生事件
    console.log('立即访问', e.type)
    setTimeout(() => {
      console.log('延迟访问', e.type)
    }, 2000)

  }
  return (
    <>
      <button onClick={handleClick}>click</button>
    </>
  )
}

image.png注意:React 17 已移除了这一机制,因为现代浏览器在垃圾回收方面已经足够高效,这一优化反而带来了开发上的困扰。

合成事件与原生事件的区别

特性 原生事件 React 合成事件
绑定方式 addEventListener onClick等props
事件委托 需手动实现 自动委托到根节点
跨浏览器兼容性 需自行处理 已统一处理
事件对象 原生Event对象 SyntheticEvent对象
性能优化 无特殊优化 自动事件池(16及之前)
阻止冒泡 stopPropagation 同左但行为更一致

合成事件的常见问题与解决方案

1. 事件冒泡与阻止冒泡

合成事件的冒泡行为与原生事件类似,但有一个关键区别:合成事件的冒泡是基于虚拟DOM而非真实DOM

jsx

function Parent() {
  const handleParentClick = () => {
    console.log('父元素点击');
  };

  return (
    <div onClick={handleParentClick}>
      <Child />
    </div>
  );
}

function Child() {
  const handleChildClick = (e) => {
    console.log('子元素点击');
    e.stopPropagation(); // 阻止冒泡到父元素
  };

  return <button onClick={handleChildClick}>点击</button>;
}

2. 与原生事件混用

当需要在React中混用原生事件时,需要注意执行顺序问题:

jsx

useEffect(() => {
  const div = document.getElementById('native-div');
  div.addEventListener('click', () => {
    console.log('原生事件触发');
  });
}, []);

function handleReactClick() {
  console.log('React事件触发');
}

// 点击时输出顺序:
// 1. 原生事件触发
// 2. React事件触发
<div id="native-div" onClick={handleReactClick}>点击我</div>

3. 异步访问事件对象

在React 16及之前版本,合成事件对象是共享重用的,异步访问会导致问题:

jsx

function handleClick(event) {
  // 解决方案1:立即读取所需属性
  const { type, target } = event;
  
  // 解决方案2:调用event.persist()保留事件对象
  event.persist();
  
  setTimeout(() => {
    console.log(type); // 正常
    console.log(event.type); // React 16中需要persist()
  }, 0);
}

React 17+  已移除此限制,可以安全地在异步代码中访问事件对象。

合成事件的性能优势

  1. 减少内存占用:通过事件委托,避免了为每个元素单独绑定事件监听器
  2. 动态内容支持:新添加的DOM节点自动具备事件处理能力,无需重新绑定
  3. 统一处理:React可以在内部优化事件处理流程,减少浏览器重绘和回流
  4. 懒加载事件:React只在需要时才会初始化特定类型的事件处理器

最佳实践

  1. 避免过度使用stopPropagation:除非必要,否则让事件自然冒泡
  2. 合理使用事件委托:对于列表项等相似元素,在父级处理事件
  3. 注意清理原生事件:在useEffect中绑定原生事件时,记得返回清理函数
  4. 优先使用合成事件:除非有特殊需求,否则应优先使用React的事件系统

jsx

// 列表项的事件委托示例
function List({ items }) {
  const handleClick = (e) => {
    // 通过dataset获取数据
    const id = e.target.dataset.id;
    if (id) {
      console.log('点击了项目:', id);
    }
  };

  return (
    <ul onClick={handleClick}>
      {items.map(item => (
        <li key={item.id} data-id={item.id}>
          {item.text}
        </li>
      ))}
    </ul>
  );
}

结论

React的合成事件系统是框架设计中的一大亮点,它不仅解决了浏览器兼容性问题,还通过智能的事件委托和优化机制,提升了大型应用的性能表现。理解合成事件的工作原理,能够帮助开发者编写更高效、更健壮的React代码,避免常见的陷阱,充分利用React框架的优势。

随着React的持续演进,事件系统也在不断优化。从React 17开始,事件委托不再绑定到document而是应用的root节点,事件池机制也被移除,这些改变都使得开发体验更加直观和友好。作为开发者,我们只需要享受这些改进带来的便利,同时理解背后的原理,以便更好地调试和优化我们的应用。

Canvas 绘制模糊?那是你没搞懂 DPR!

作者 良辰未晚
2025年7月8日 18:49

在 Web 开发中,HTML <canvas> 元素为我们提供了强大的图形绘制能力。但很多开发者在使用 Canvas 时,会遇到一个令人困惑的问题:为什么我明明按照 CSS 设定的尺寸来绘制,最终结果却模糊、错位,或者显示不全? 尤其是在 Retina 或其他高分辨率(HiDPI)屏幕上,这个问题更是雪上加霜。

这背后隐藏的“秘密”就是 Canvas 元素的两个尺寸概念,以及不可忽视的设备像素比(Device Pixel Ratio, DPR) 。理解并掌握它们的完美同步,是实现像素级清晰 Canvas 绘制的关键。


Canvas 的两个“身份”:内部分辨率 vs. 外部大小

要理解 Canvas 绘制的问题,首先要明白它有两个截然不同的尺寸属性:

  1. Canvas 内部绘图表面尺寸 (The Drawing Surface Size)

    • 定义方式:通过 <canvas> 标签上的 HTML 属性 widthheight 来设置,例如 <canvas width="300" height="150"></canvas>
    • 作用:这定义了 Canvas 内部有多少个逻辑像素点供你绘制。当你使用 ctx.fillRect(0,0,10,10) 这样的指令时,操作的就是这个内部的像素网格。你可以把它想象成一张图片本身的分辨率
    • 默认值:如果不在 HTML 属性中指定,默认是 width="300"height="150"
  2. Canvas 元素在 DOM 中的渲染尺寸 (The Rendering Size)

    • 定义方式:通过 CSS 属性 widthheight 来设置,例如 canvas { width: 100%; height: 200px; }
    • 作用:这决定了 Canvas 元素在网页布局中实际占据的物理空间大小。这是你在浏览器中用肉眼看到的 Canvas 大小,也是通过 canvas.clientWidthcanvas.clientHeight 获取到的值。
    • 关系:浏览器会将 Canvas 内部的绘图表面拉伸或压缩到这个 CSS 定义的渲染尺寸上。

问题之源:当 Canvas 的渲染尺寸(由 CSS 决定)与它的内部绘图表面尺寸(由 HTML 属性决定)不一致时,就会发生内容拉伸。例如,你内部只有 300x150 像素,却要显示在 600x300 的空间里,内容自然会变模糊。鼠标事件的坐标(基于 CSS 渲染尺寸)也可能因此与内部绘图坐标产生偏差。


设备像素比 (DPR) 的隐形影响

仅仅同步 Canvas 的内部尺寸和渲染尺寸还不足以解决所有问题,尤其是在高 DPI 屏幕(如手机、Retina MacBook)上。这时,设备像素比 (Device Pixel Ratio, DPR) 登场了。

  • DPR 的定义window.devicePixelRatio 返回的值,表示一个 CSS 像素 对应多少个 物理像素

    • 普通屏幕 (Standard-DPI):DPR 通常是 1 (1个CSS像素 = 1个物理像素)。
    • 高 DPI 屏幕 (HiDPI):DPR 通常是 23,甚至 1.5(就像我们之前遇到的情况)。这意味着 1 个 CSS 像素实际上由多个(例如 2x2 或 1.5x1.5)物理像素组成,目的是为了让屏幕显示更细腻。

DPR 带来的挑战:如果你只是简单地让 Canvas 的内部尺寸等于其 CSS 渲染尺寸(例如都是 300x100),那么在 DPR 为 2 的屏幕上,这 300x100 个 Canvas 内部像素会被浏览器拉伸到 600x200 的物理像素区域上。结果就是,原本清晰的线条变得模糊,文字不再锐利,因为每个“逻辑像素”都被强制放大到了多个物理像素。


完美同步的奥秘:Canvas 适配 DPR 的策略

要彻底解决 Canvas 的模糊、错位和右侧留白问题,我们需要一个策略,让 Canvas 的内部绘图表面与它在屏幕上实际显示的物理像素 1:1 对应,同时又保持我们用 CSS 像素单位进行绘图的便利性。

核心步骤如下:

  1. 获取 Canvas 的实际 CSS 渲染尺寸: 这是你的 Canvas 元素在浏览器布局中实际占据的空间大小,是用户肉眼看到的尺寸。

    JavaScript

    const canvas = sliderCanvas.value; // 获取 Canvas DOM 元素
    const cssWidth = canvas.clientWidth;  // 例如:300px
    const cssHeight = canvas.clientHeight; // 例如:100px
    

    提示:在 Vue 的 onMounted 钩子中获取 clientWidth/clientHeight 时,最好使用 nextTick,以确保 DOM 布局已完全稳定。同时,为了响应式布局,要监听 window.resize 事件并重新执行绘制。

  2. 获取设备的像素比 (DPR)

    JavaScript

    const dpr = window.devicePixelRatio || 1; // 例如:1.5
    
  3. 动态调整 Canvas 的内部绘图表面尺寸 (HTML 属性) : 这是最关键的一步。我们将 Canvas 的 HTML widthheight 属性设置为其 CSS 渲染尺寸乘以 DPR。

    JavaScript

    canvas.width = cssWidth * dpr;   // 例如:300 * 1.5 = 450
    canvas.height = cssHeight * dpr; // 例如:100 * 1.5 = 150
    

    现在,Canvas 内部有了 450x150 个逻辑像素,这些像素正好能够 1:1 地映射到 300x100 CSS 像素在高 DPR 屏幕上对应的物理像素区域。

  4. 缩放 Canvas 2D 绘图上下文 (Context) : 在调整了 Canvas 的内部尺寸后,ctx 的坐标系也相应变大了 dpr 倍。为了让我们在后续的绘图指令中仍然能够以直观的 CSS 像素单位(例如,绘制一个 100px 宽的矩形)进行操作,我们需要将 ctx 的坐标系反向缩放 dpr 倍。

    JavaScript

    const ctx = canvas.getContext('2d');
    ctx.scale(dpr, dpr);
    

    现在,当你调用 ctx.fillRect(0, 0, 300, 100) 时,ctx 会自动将其内部绘制成 300*dpr x 100*dpr 的物理像素,完美匹配 Canvas 的内部物理分辨率。


完整的代码示例(核心部分)

将这些原理应用到 Vue Canvas 组件中,核心代码会是这样的:

JavaScript

// ... (Vue script setup 部分) ...

const sliderCanvas = ref(null);
let ctx = null;
let dpr = 1; // 定义设备像素比

const drawSlider = () => {
  const canvas = sliderCanvas.value;
  if (!ctx || !canvas) return;

  // 1. 获取 Canvas 的 CSS 渲染尺寸
  const cssWidth = canvas.clientWidth; 
  const cssHeight = canvas.clientHeight; 

  // 2. 根据 DPR 调整 Canvas 内部绘图表面尺寸
  canvas.width = cssWidth * dpr;
  canvas.height = cssHeight * dpr;

  // 3. 缩放绘图上下文,使后续操作基于 CSS 像素单位
  ctx.scale(dpr, dpr);

  // 清除 Canvas,注意尺寸使用 CSS 尺寸,因为 ctx 已被 scale
  ctx.clearRect(0, 0, cssWidth, cssHeight); 

  // 4. 所有绘图操作都使用 CSS 尺寸进行计算和绘制
  // 例如:绘制轨道背景
  ctx.fillStyle = props.trackBgColor;
  ctx.fillRect(0, trackY, cssWidth, trackHeight); // 宽度使用 cssWidth

  // ... 绘制手柄,计算手柄位置等,都使用 cssWidth/cssHeight ...
  // 例如:手柄 X 坐标 = currentMinPercent.value * cssWidth;
};

onMounted(() => {
  ctx = sliderCanvas.value.getContext('2d');
  dpr = window.devicePixelRatio || 1; // 获取 DPR

  // 确保在 DOM 布局完成后再进行首次绘制
  nextTick(() => {
    drawSlider(); 
  });

  // 监听窗口大小变化,以实现响应式 Canvas 绘制
  window.addEventListener('resize', drawSlider);
});

onUnmounted(() => {
  ctx = null;
  window.removeEventListener('resize', drawSlider);
});

总结

通过理解 Canvas 的内部绘图表面尺寸外部渲染尺寸以及设备像素比 (DPR) 之间的关系,并采取相应的同步和缩放策略,我们能够:

  • 消除模糊:在所有屏幕上都能绘制出锐利、清晰的图形。
  • 解决错位:鼠标事件的坐标与 Canvas 上的绘制内容完美对齐。
  • 实现响应式:Canvas 能够根据其在 DOM 中的实际大小动态调整,确保内容始终正确填充。

我的文章如果对您有所启发,希望您能点赞支持一下哦

Web第二次笔记

2025年7月8日 18:45

1.动画-animation

1.1 动画-单状态

image.png 必写:
@keyframes:关键帧

<!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>
   /* 定义动画:多状态*/
   @keyframes ani {
     from {
       background-color: red;
     }

     to {
       background-color: blue;
     }
   }

   /* 使用动画 (补帧动画:中间帧会系统自动补全)*/
   div {
     width: 200px;
     height: 200px;
     border: 1px solid #000;
     margin: 100px auto;
     /* animation: 动画名称 动画时间; */
     animation: ani 10s;
   }
 </style>
</head>

<body>
 <div></div>
</body>

</html>

结果:

image.png

image.png

image.png

image.png

1.2 动画-多状态

<!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>
    /* 定义动画:多状态*/
    @keyframes ani {
      0% {
        background-color: red;
      }

      10% {
        background-color: blue;
      }

      20% {
        background-color: yellow;
      }

      30% {
        background-color: green;
      }

      40% {
        background-color: orange;
      }

      50% {
        background-color: blue;
      }

      60% {
        background-color: yellow;
      }

      70% {
        background-color: green;
      }

    }

    /* 使用动画 (补帧动画:中间帧会系统自动补全)*/
    div {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      /* background-color: pink; */
      /* animation: 动画名称 动画时间; */
      margin: 100px auto;
      animation: ani 10s;
    }
  </style>
</head>

<body>
  <div></div>
</body>

</html>

1.3 动画-属性

image.png

1.3.1 animation-name&animation-duration

      animation: ani 10s;
      /* 可以写成 */
      animation-name: ani;
      animation-duration: 10s;

1.3.2 animation-delay:延迟显示

<!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>
    .box {
      width: 100%;
      height: 600px;
      border: 1px solid #000;
      display: flex;
      justify-content: space-evenly;
      align-items: end;
    }

    .son {

      left: 0;
      bottom: 0;
      width: 50px;
      height: 60px;
      border-radius: 25px;
      background-color: pink;
      animation-name: move;
      animation-duration: 5s;
    }


    @keyframes move {
      0% {
        transform: translateY(0px);
      }

      25% {
        transform: translateY(-400px);
      }

      50% {
        transform: translateY(0px);
      }

      75% {
        transform: translateY(-400px);
      }

      100% {
        transform: translateY(0px);
      }
    }

    .son:nth-child(1) {
      animation-delay: 0s;
    }

    .son:nth-child(2) {
      animation-delay: 0.5s;
    }

    .son:nth-child(3) {
      animation-delay: 1s;
    }
  </style>
</head>

<body>
  <div class="box">
    <div class="son"></div>
    <div class="son"></div>
    <div class="son"></div>
  </div>
</body>

</html>

1.3.3 animation-fill-mode:动画执行完毕状态(了解)

forwards:最后一帧状态
backwards:第一帧状态

<!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>
    .box {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 3s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 动画状态控制 */
      animation-fill-mode: forwards;
    }

    @keyframes move {
      0% {
        transform: translateX(200px);
        background-color: green;
      }

      100% {
        transform: translateX(800px);
        background-color: blue;
      }

    }
  </style>
</head>

<body>
  <div class="box"></div>
</body>

</html>

1.3.4 animation-time-function:速度曲线

<!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>
    .box:nth-child(1) {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 6s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 默认速度曲线 */
      /* 默认:慢-快-慢 */
      /* linear:匀速 */
      animation-timing-function: linear;
    }

    .box:nth-child(2) {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 6s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 默认速度曲线 */
      /* animation-timing-function: ; */
    }

    .box:nth-child(3) {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 6s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 默认速度曲线  */
      /* steps():不是自动补帧,是逐帧动画 */
      animation-timing-function: steps(8);
    }

    @keyframes move {
      0% {
        transform: translateX(0px);
        background-color: green;
      }

      100% {
        transform: translateX(1000px);
        background-color: blue;
      }

    }
  </style>
</head>

<body>
  <div class="box"></div>
  <div class="box"></div>
  <div class="box"></div>
</body>

</html>

1.3.5 animation-iteration-count:重复次数

infinite为无限循环

<!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>
    .box {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 2s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 默认速度曲线 */
      /* 默认:慢-快-慢 */
      animation-iteration-count: infinite;
      animation-fill-mode: forwards;
    }

    @keyframes move {
      0% {
        transform: translateX(0px);
        background-color: yellow;
      }

      50% {
        transform: translateX(1000px);
        background-color: blue;
      }

      100% {
        transform: translateX(0px);
        background-color: yellow;
      }

    }
  </style>
</head>

<body>
  <div class="box"></div>

</body>

</html>

1.3.6 animation-direction:动画执行方向

设置为反向播放 :alternate
animation-direction: alternate;

<!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>
    .box {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 2s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 播放方向 */
      /* 1.默认按照 设置关键帧顺序播放 */
      /* 2.设置为反向播放 :alternate*/
      animation-direction: alternate;
      animation-iteration-count: infinite;
    }

    @keyframes move {
      0% {
        transform: translateX(0px);
        background-color: yellow;
      }

      100% {
        transform: translateX(1000px);
        background-color: blue;
      }

      /* 100% {
        transform: translateX(0px);
        background-color: yellow;
      } */

    }
  </style>
</head>

<body>
  <div class="box"></div>

</body>

</html>

1.3.7 animation-play-state:播放状态(暂停)

<!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>
    .box {
      width: 200px;
      height: 200px;
      border: 1px solid #000;
      animation: move 2s;
      /* 延迟1s后开始动画 */
      animation-delay: 1s;

      /* 播放方向 */
      /* 1.默认按照 设置关键帧顺序播放 */
      /* 2.设置为反向播放 :alternate*/
      /* 了解补充:反向reverse : anmation-direction:reverse; */
      animation-direction: alternate;
      animation-iteration-count: infinite;
    }

    /* 配合鼠标进入的时候动画暂停 */
    .box:hover {
      animation-play-state: paused;
    }

    @keyframes move {
      0% {
        transform: translateX(0px);
        background-color: yellow;
      }

      100% {
        transform: translateX(1000px);
        background-color: blue;
      }

      /* 100% {
        transform: translateX(0px);
        background-color: yellow;
      } */

    }
  </style>
</head>

<body>
  <div class="box"></div>

</body>

</html>

面试必问:JS 事件机制从绑定到委托,一篇吃透所有考点

作者 然我
2025年7月7日 20:55

JS 事件机制是前端面试的 “硬核考点”,从 DOM0 到 DOM2 的绑定区别,到捕获与冒泡的执行流程,再到事件委托的优化逻辑,面试官总能从里问到外,这篇文章带你吃透所有考点!

事件绑定:DOM0 和 DOM2 的区别,面试先问这个

给元素绑定事件,本质是告诉浏览器:“当某个动作(比如点击)发生时,执行我写的函数”。但绑定方式有两种,各有各的特点,面试官最爱问 “这俩有啥区别”。

(1)DOM0 级事件:简单但有局限的 “老式绑定”

直接通过on+事件名绑定,有两种写法:

<!-- 写法1:HTML与JS耦合(不推荐) -->
<button onclick="handleClick()">点击</button>
<script>
  function handleClick() {
    console.log('点击事件触发');
  }
</script>

<!-- 写法2:JS中直接赋值(稍好但仍有局限) -->
<script>
  const btn = document.querySelector('button');
  btn.onclick = function() {
    console.log('DOM0:点击触发');
  }
</script>

核心特点

  • 同一事件只能绑定一个函数:如果再写btn.onclick = 新函数,旧函数会被直接覆盖(比如先绑了fn1,再绑fn2,触发时只执行fn2)。
  • 只能在冒泡阶段触发:没有控制 “捕获 / 冒泡” 的能力,事件触发时机固定。
  • 耦合性高:写法 1 中 HTML 和 JS 混在一起,改逻辑时要同时动两个地方,维护麻烦(违反 “HTML 管结构、JS 管逻辑” 的分离原则)。

DOM2 级事件:更灵活的 “现代绑定”

addEventListener方法绑定,是现在的主流写法:

const btn = document.querySelector('button');
// 绑定点击事件
btn.addEventListener('click', function() {
  console.log('DOM2:点击触发');
}, false); // 第三个参数控制阶段,默认false

参数说明

image.png

image.png

  • 第一个参数:事件类型(如'click''input')。
  • 第二个参数:事件触发时执行的回调函数(监听器)。
  • 第三个参数useCapture:布尔值,true表示事件在 “捕获阶段” 触发,false(默认)表示在 “冒泡阶段” 触发(核心考点,后面详说)。

核心优势

  • 同一事件可绑定多个函数:比如再绑一个click事件,两个函数会按绑定顺序执行(不会覆盖)。

    btn.addEventListener('click', () => console.log('函数1'), false);
    btn.addEventListener('click', () => console.log('函数2'), false);
    // 点击后输出:函数1 → 函数2
    
  • 支持控制事件阶段:通过useCapture参数,可指定事件在 “捕获阶段” 还是 “冒泡阶段” 触发(DOM0 做不到)。

  • 解耦 HTML 和 JS:事件逻辑全在 JS 中,HTML 只负责结构,改代码时不用两头找。

(3)冷知识:为啥没有 DOM1 级事件?

很多人疑惑 “DOM0、DOM2 都有,DOM1 去哪了?”—— 其实 DOM 规范的版本迭代里,DOM1(1998 年)只定义了基础的 DOM 操作接口(比如getElementByIdappendChild),压根没涉及事件机制。事件相关的规范是到 DOM2(2000 年)才正式加入的,这也是 “DOM2 级事件” 名字的由来。面试提一句,能体现你对规范的了解。

事件流:捕获→目标→冒泡

当你点击一个元素时,事件不是只在这个元素上触发就结束,而是会经历 “捕获→目标→冒泡” 三个阶段,这就是 “事件流”。理解这个流程,才能说清 “父元素和子元素的事件谁先执行”。

(1)三个阶段的具体流程

假设页面结构是:document → html → body → 父元素 → 子元素(子元素是点击目标),事件流会按以下顺序执行:

  1. 捕获阶段:从最顶层的document开始,逐层向下 “查找” 目标元素,依次触发绑定了useCapture=true的事件。
    路径:document → html → body → 父元素 → 子元素(如果父 / 子元素的事件绑了useCapture=true,会在这一步触发)。
  2. 目标阶段:事件到达实际点击的元素(子元素),触发该元素的事件(不管useCapturetrue还是false)。
  3. 冒泡阶段:从目标元素开始,逐层向上 “返回” 到document,依次触发绑定了useCapture=false(默认)的事件。
    路径:子元素 → 父元素 → body → html → document(如果父 / 子元素的事件绑了useCapture=false,会在这一步触发)。

(2)代码示例:执行顺序一目了然

<div id="parent" style="padding: 50px; background: #eee;">
  父元素
  <div id="child" style="padding: 20px; background: #ccc;">子元素</div>
</div>

<script>
  // 父元素绑定事件,useCapture=true(捕获阶段触发)
  document.getElementById('parent').addEventListener('click', () => {
    console.log('父元素:捕获阶段');
  }, true);

  // 子元素绑定事件,useCapture=true(捕获阶段触发)
  document.getElementById('child').addEventListener('click', () => {
    console.log('子元素:捕获阶段');
  }, true);

  // 父元素绑定事件,useCapture=false(冒泡阶段触发)
  document.getElementById('parent').addEventListener('click', () => {
    console.log('父元素:冒泡阶段');
  }, false);

  // 子元素绑定事件,useCapture=false(冒泡阶段触发)
  document.getElementById('child').addEventListener('click', () => {
    console.log('子元素:冒泡阶段');
  }, false);
</script>

点击子元素后,输出顺序是

image.png

(3)关键结论:useCapture决定触发阶段

  • 绑了useCapture=true的事件,在捕获阶段触发(从上到下)。
  • 绑了useCapture=false(默认)的事件,在冒泡阶段触发(从下到上)。
    面试时被问 “父元素和子元素的事件执行顺序”,直接按这个规则推导即可。

事件委托:优化性能的实战技巧,面试必问优势

如果有 100 个列表项需要绑定点击事件,不用给每个项都绑一次,而是把事件绑在它们的父元素上,利用 “冒泡阶段” 统一处理 —— 这就是 “事件委托”。

(1)核心代码:3 行实现事件委托

<ul id="myList">
  <li>item1</li>
  <li>item2</li>
  <li>item3</li>
  <!-- 可能有更多li -->
</ul>

<script>
  // 事件绑在父元素ul上,利用冒泡阶段触发
  document.getElementById('myList').addEventListener('click', (event) => {
    // 通过event.target判断实际点击的是哪个子元素(li)
    if (event.target.tagName === 'LI') {
      console.log('点击了:', event.target.textContent);
    }
  }, false);
</script>

原理:当点击li时,事件会冒泡到父元素ulul的事件处理函数通过event.target(指向实际点击的li)判断目标,从而执行对应逻辑。

image.png

(2)事件委托的 3 个核心优势

  1. 减少事件绑定次数,优化性能:100 个li只需绑 1 次事件,而不是 100 次,减少浏览器内存占用(尤其在列表项极多的场景,性能提升明显)。
  2. 支持动态新增元素:如果后续通过 JS 动态添加新的li(比如从后端加载数据),新元素不用重新绑定事件,父元素的监听器会自动处理(比 “新增一个绑一次” 简洁太多)。
  3. 集中管理事件逻辑:所有子元素的事件处理都在父元素的一个函数里,不用分散在多个地方,维护更方便。

避坑指南:这些细节容易答错

  1. DOM0 和 DOM2 混用会出问题
    如果同时用onclick(DOM0)和addEventListener(DOM2)绑定同一事件,DOM0 会覆盖 DOM2 的事件:

    const btn = document.querySelector('button');
    btn.addEventListener('click', () => console.log('DOM2'), false);
    btn.onclick = () => console.log('DOM0'); // DOM0会覆盖DOM2
    // 点击后只输出:DOM0
    

    结论:统一用 DOM2 的addEventListener,避免混用。

  2. event.targetthis的区别

    • event.target:指向 “实际触发事件的元素”(比如事件委托中,指被点击的li)。
    • this:指向 “绑定事件的元素”(比如事件委托中,指父元素ul)。
  3. 不是所有事件都能冒泡
    大部分事件(如clickinput)会冒泡,但少数事件(如focusblur)不会,使用时要注意。

技术解析:高级 Excel 财务报表解析器的架构与实现

2025年7月7日 17:29

做企业报表解析的同学大概都知道,Excel 文件的世界比网页还混乱。字段乱、表头不定、合并单元格、格式各种魔改,尤其是财务场景下,那种“看起来像表,但其实什么都不是”的 Excel,比比皆是。

这样的背景下,我遇到了这个典型难题:解析一堆格式不统一的 Excel 报表

为了满足自动化解析、结构化提取、异常检测与标准输出的需求,我构建了一个高可扩展性的 Excel 报表解析系统——AdvancedExcelParser

本篇文章将深入剖析其架构设计、技术细节与业务价值。冲!!

image.png

1、分而治之

不同类型的报表,它的表格结构、字段意义、甚至数据粒度都完全不同。预核对表是按月份汇总的统计值,账单报表是细颗粒度的明细数据,分成表又是以供应商编号为核心的结构。我没法用一个“万能解析器”搞定所有,所以决定每种报表单独写一个解析方法,比如:

parse_billing_report()
parse_partner_sharing_report()
parse_terminal_service_report()

这样做的好处是,每一套逻辑都能针对性地写死,灵活性高,结构也清晰。哪怕以后格式变化,只改对应的方法就好,不会互相影响。

这不是最“优雅”的方案,但在企业实战里,这种分治方式是最稳的。

有些报表写得“看起来像个表”,但其实前几行都是备注、说明、版本号,真正的表头在第五行甚至第七行。

我干脆预读前20行,然后用字符串拼接 + 正则判断,看哪一行同时包含“编号”“计费数量”“总金额”这些关键词。只要找到了,我就以那一行作为 header,把 DataFrame 重新读取一遍。这样就解决了“表头定位不确定”的问题。

2、数据清洗

Excel 报表从来不会给你干净的数据。有合并单元格的,有空行空列的,有些字段看着像数字,结果其实是字符串“--”或者“1,234”。所以我统一做了清洗处理,包括:去掉空行列、填 NaN 为 0、所有 object 类型的字段尝试转成 float。

有时候,字段名也不是唯一的,所以我引入一套模糊字段匹配机制。

比如只要字段名里出现“计费”和“数量”,而不包含“累计”,我就认为它是当前月份的计费数量。通过类似的规则,我基本上可以自动把各种乱七八糟的字段归到统一的字段字典里。

像账单和分成报表,大多数情况下是结构化表格,我可以通过列名 + 行筛选快速定位。但预核对表和一些特殊的模板就比较麻烦了,它其实是一堆单元格坐标上的数据,完全不符合 DataFrame 的范式。

所以我在这类报表中使用 openpyxl,通过 cell(row=x, column=y) 方式直接定位内容。比如我要取“有效话单数量”,那就写死取 M3 单元格。

这个方法尤其适合已经定型的模板,只要不大改格式,就能保证提取稳定。

3、保证输出

不管输入多么混乱,最终我要生成的是结构化的数据格式:字典或列表。比如:

{
  "编号01": {
    "计费数量": 123456,
    "历史累计计费数量": 2345678
  }
}

或者:

[  {    "月份": "2025年6月",    "供应商-厂商": "XXX",    "总金额": 34862.12  }]

这些结构可以直接被前端渲染、被 API 返回,或者被报告生成模块引用。

只有结构化数据才能被比对、能被融入自动化的流程,才是真正的“可用”。

4、错误收集

所有验证信息记录于 validation_errors,可用于 UI 提示、日志上报或人工复查。这相当于给系统加了一套“弱类型单元测试”,保证“输出的结构虽然合法,但值必须合理”。

if value < 0:
  self.validation_errors.append(f"{category}-{item}: 数值不能为负")

整个 AdvancedExcelParser 的设计:

能力模块 描述
表头识别 自动定位字段所在行
字段映射 不同命名归一化
数据清洗 缺失值填补、类型统一
提取策略 支持结构化/非结构化/正则/坐标多策略
日志体系 INFO + WARNING + ERROR 多级日志记录
异常容忍 出错不崩溃,集中上报
验证模块 基本规则校验与异常标注
输出结构 面向消费端统一结构 JSON / dict / list

最终实现,这不仅仅是一个函数集合,而是一个“企业数据入湖网关”。

OK,以上便是本次分享~

欢迎加我:atar24,进技术群、交盆友,我会第一时间通过

深入CSS层叠的本质:@layer如何优雅地解决样式覆盖与!important滥用问题

作者 ErpanOmer
2025年7月7日 17:17

image.png 咱们做前端的,每天都有不可避免的事情—— CSS样式覆盖

场景你一定不陌生:项目越来越大,引入了UI组件库,又来了几个新同事,大家写的CSS文件越来越多。突然有一天,产品经理让你改一个按钮的颜色,你就写下 .my-button { background-color: blue; },刷新一看,没反应!

打开F12,发现你的样式给覆盖了:

#app .main-content .card-list .card:nth-child(3) .button { background-color: red; }

怎么办?你的第一反应通常是把自己的选择器也写得更长、更具体,用更高的“权重”把它怼回去。这就是“权重”的开始。

这时候终极方案——!important——往往就会登场。

!important 可以瞬间解决眼前的冲突,但它带来的副作用是长期的。今天你用一个,明天同事为了覆盖你的,就得用两个。最终,项目里!important满天飞,CSS变得像一坨屎,彻底失控。

那么解决这个问题,有希望吗?

有。今天,我们就来聊聊CSS官方给我们的分层协议—— @layer


“层叠”的规则

在介绍@layer之前,我们得花一分钟,快速回顾一下CSS“层叠”(Cascade)这个词的本来意思。当多个样式规则都想作用于同一个元素时,它会根据一套严格的标准来决定谁在最上面,谁在最底层。

这个标准,简单说就三条,优先级从上到下:

  1. 来源和重要性:浏览器默认样式 < 我们写的样式 < 我们写的带!important的样式。
  2. 权重(Specificity) :这是我们最熟悉的方式。ID选择器(如#id) > 类选择器/属性选择器(如.class, [type="text"]) > 标签选择器(如div)。
  3. 代码顺序:如果上面两条都一样,那简单粗暴,谁写在后面,谁会覆盖前面的。

过去,我们能掌控的,基本就只有第2条和第3条。所以大家只能在“提权重”和“改顺序”这两条路上卷。


新的规则:@layer

好了,@layer 是什么?

你可以把它理解成,CSS官方提供的一种给样式“分层”的工具,就像Photoshop或Figma里的图层一样。

它引入了一个全新的、优先级甚至高于“权重” 的判断标准。

@layer的核心规则就一句话:层叠顺序(Cascade Layers)的优先级,高于单个选择器的权重。你定义图层的顺序,决定了最终谁胜出。

这听起来有点抽象,我们直接上代码,一看就懂。

假设我们先在CSS文件的顶部,声明定义好我们的图层顺序:

@layer reset, base, components, utilities;

这行代码的意思是:reset层的样式最先被考虑(最底层),utilities层的样式最后被考虑(最顶层)。

现在,我们写两条规则,一条权重很高,但放在了底层的base里;另一条权重很低,但放在了顶层的utilities里:

/* 把它放进 base 图层 */
@layer base {
  /* 一个权重很高的选择器 (1个ID + 1个标签) */
  #main p {
    color: blue;
  }
}

/* 把它放进 utilities 图层 */
@layer utilities {
  /* 一个权重很低的选择器 (1个类) */
  .text-red {
    color: red;
  }
}

然后我们这么用:

<div id="main">
  <p class="text-red">这段文字会是什么颜色?</p>
</div>

按照老的“权重”规则,#main p 的权重(101)远大于 .text-red 的权重(10),所以文字应该是蓝色的。

但用了@layer之后,结果是:文字会是红色的!

为什么?因为utilities这个图层,在我们最开始定义时,就排在了base图层的后面。所以,无论base层里的选择器权重有多高,它都打不过utilities层里的规则。

这就是@layer最有意思的地方:它让我们从关注单个选择器,上升到了样式架构层面。


在真实项目中,我们该怎么用@layer

理解了原理,我们来看看在实际项目中如何搭建这个分层架构。这是我现在常用的一个分层结构:

/* 1. 定义所有图层的顺序 */
@layer reset, base, library, components, utilities, overrides;

/* 2. 把不同类型的样式,放进对应的图层 */

/* reset.css 或 normalize.css */
@layer reset {
  /* ...重置浏览器的默认样式... */
  * { box-sizing: border-box; }
}

/* 基础元素样式 */
@layer base {
  body { font-family: sans-serif; }
  a { color: #333; }
}

/* 引入第三方UI库,比如Ant Design Vue */
@import url('ant-design-vue/dist/reset.css') layer(library);
@layer library {
  /* 你可以写一些覆盖UI库的全局样式 */
}

/* 我们自己的业务组件 */
@layer components {
  .card { border: 1px solid #eee; }
  .button { padding: 8px 16px; }
}

/* 工具类,比如Tailwind里的 */
@layer utilities {
  .text-center { text-align: center; }
  .p-4 { padding: 1rem; }
}

/* 最后的“救命稻草”层,用来覆盖一切 */
@layer overrides {
  .some-very-specific-case {
    /* ... */
  }
}

这个结构的好处是:

  • 第三方库的样式再也不会“一手遮天”了。通过@import ... layer(library),我们能把整个UI库的样式都关进一个可控的图层里。
  • 工具类的优先级得到了保证。像.text-center这种工具类,我们总是希望它能覆盖掉组件的默认样式,现在把它放在utilities层,就能轻松实现。
  • 我们几乎不再需要!important。以前需要用!important的场景,现在大部分都可以通过把覆盖样式写在utilitiesoverrides层来解决,代码变得干净、可预测。

@layer 不是一个小技巧,它是CSS发展史上的一个里程碑。它为我们提供了一个前所未有的处理方式,来管理CSS的复杂性。

它让我们写CSS的思路,从“我该怎么提高这个选择器的权重去覆盖它?”,转变成了“我应该把这个样式规则放在哪个图层才最合理?”。

image.png

浏览器兼容性方面,截至2025年7月,所有主流浏览器都已支持(简单粗暴统计🙂)。所以,别犹豫了,在你的下一个项目中,尝试用@layer来构建你的CSS架构吧。

谢谢大家🌼

每个前端开发者都应该掌握的几个 ReactJS 概念

作者 MiyueFE
2025年7月7日 14:37

原文:《Mastering Advanced ReactJS Concepts: Essential Knowledge for Every Frontend Developer》

作者:Shivam Bhadani

在这篇博客里,我们要一口气搞定所有 ReactJS 的高级概念!看完这篇,前端面试你就能横着走,甚至还能自己造个 ReactJS 一样的库,想想都刺激!

本文目录

  1. 什么是渲染?它是怎么发生的?
  2. 什么是重新渲染?组件什么时候会重新渲染?
  3. 认识虚拟 DOM
  4. 什么是协调算法?
  5. ReactJS 的性能优化

什么是渲染?它是怎么发生的?

如果你用过 ReactJS,肯定没少听"渲染"这个词。今天我们就来深挖一下它。

在 ReactJS 里,我们写的是 JSX。JSX 就是 JavaScript XML,是 ReactJS 发明的一种语法,看起来像 HTML。可浏览器只认 JavaScript,不认 JSX!所以,JSX 得先变成 JavaScript,这活儿就交给了 ReactJS 的编译器——Babel

Babel 是怎么把 JSX 变成 JavaScript 的?

Babel 会把 JSX 变成 React.createElement() 的调用,这个函数会返回一个 JavaScript 对象,叫做 React 元素(React Element)

记住下面这句话,刻进 DNA 里:我们的终极目标就是从 JSX 得到 React Element。你可以手写 React.createElement(),也可以写 JSX 让 babel 自动帮你转。

React.createElement() 是啥?

React.createElement() 是 React 里最底层的造元素(节点)函数。JSX 其实就是 React.createElement()语法糖

我们先学怎么把 JSX 翻译成 React.createElement(),再看看这个函数到底返回了啥。

比如你写了这么个 JSX:

const jsx = <h1>Hello, React!</h1>;

它会被转成:

const element = React.createElement("h1", null, "Hello, React!");

React.createElement() 的语法

React.createElement(type, props, ...children)

参数说明:

type (字符串或组件)

  • 如果是字符串,就代表普通 HTML 标签(比如 "h1"、"div")。
  • 如果是函数或类,就代表 React 组件。

props (对象)

  • 标签上的属性,比如 classNameidonClick 等。
  • 没有属性就写 null。

...children (可选)

  • 元素里的内容(文本、其他元素或组件)。

img

例子1

JSX:

const Jsx = <h1>Hello, React!</h1>;

转成 React.createElement 调用:

const element = React.createElement("h1", null, "Hello, React!");

例子2

JSX:

const Jsx = <h1 className="title">Hello, React!</h1>;

转成 React.createElement 调用:

const element = React.createElement("h1", { className: "title" }, "Hello, React!");

例子3:

JSX:

<div>
  <h1>Hello</h1>
  <p>Welcome to React</p>
</div>

转成 React.createElement 调用:

const element = React.createElement(
  "div",
  null,
  React.createElement("h1", null, "Hello"),
  React.createElement("p", null, "Welcome to React")
);

例子4:

JSX:

const Jsx = <Card data = {cardData} />

转成 React.createElement 调用:

const element = React.createElement(Card, { data: cardData })

我说过,children 是可选的,没有就省略。

复杂点的例子

简单的例子都懂了吧?来个复杂点的,彻底搞明白:

JSX:

function App() {
  return (
    <div className="container">
      <h1>Welcome to React</h1>
      <UserCard name="Shivam" age={22} />
      <button onClick={() => alert("Button Clicked!")}>Click Me</button>
    </div>
  );
}

function UserCard({ name, age }) {
  return (
    <div className="user-card">
      <h2>{name}</h2>
      <p>Age: {age}</p>
    </div>
  );
}

我们一步步来:

第一步:转 UserCard 组件

function UserCard(props) {
  return React.createElement(
    "div",
    { className: "user-card" },
    React.createElement("h2", null, props.name),
    React.createElement("p", null, `Age: ${props.age}`)
  );
}

第二步:转 App 组件

function App() {
  return React.createElement(
    "div",
    { className: "container" },
    React.createElement("h1", null, "Welcome to React"),
    React.createElement(UserCard, { name: "Shivam", age: 22 }),
    React.createElement(
      "button",
      { onClick: () => alert("Button Clicked!") },
      "Click Me"
    )
  );
}

现在你应该明白了,JSX 怎么变成 React.createElement() 的。你可以直接写 HTML 风格的 JSX,也可以手写 createElement(),反正最后 babel 都会帮你转成后者。

React.createElement() 的输出长啥样?

这个函数会返回一个朴实无华的 JavaScript 对象,这个对象就叫 React 元素(React Element)

React.createElement(type, props, ...children)

它返回的对象大概长这样:

{
  type: "",
  props: {
    
  },
  key: "",
  ref: ""
}

注意:props 里不仅有标签属性,还有 children。

<div>hello</div><div chilren="hello" /> 是一回事。所以 props 里会有 children。

例子:

const element = React.createElement("h1", { className: "title" }, "Hello, React!");
console.log(element);

输出:

{
  type: "h1",
  props: {
    className: "title",
    children: "Hello, React!"
  },
  key: null,
  ref: null,
  _owner: null,
  _store: {}
}

拆解一下输出

  • type: "h1" → 告诉 React 这是个 <h1> 标签。
  • props → 这里面有属性(比如 className)和子元素。 className: "title" → 这是传给 <h1> 的 className。 children: "Hello, React!"<h1> 里的内容。
  • key → 如果你用 map 渲染列表,肯定传过 key,这就是那个 key。后面会讲 key 的真正用法。
  • ref → 用来直接操作 DOM。如果你用过 useRef(),就懂。
  • _owner_store → React 内部用的,初学者可以无视。

例子

JSX:

<div id="container">
  <h1>Hello</h1>
  <p>Welcome to React</p>
</div>

React.createElement 调用:

const element = React.createElement(
  "div",
  { id: "container" },
  React.createElement("h1", null, "Hello"),
  React.createElement("p", null, "Welcome to React")
);
console.log(element);

输出

{
  type: "div",
  props: {
    id: "container",
    children: [
      {
        type: "h1",
        props: { children: "Hello" },
        key: null,
        ref: null
      },
      {
        type: "p",
        props: { children: "Welcome to React" },
        key: null,
        ref: null
      }
    ]
  },
  key: null,
  ref: null
}

我觉得例子已经够多了,这下你肯定明白了吧?还有疑问欢迎评论区提问!

渲染到底是啥?

在 React 里,渲染就是把 React 元素(JSX 或 React.createElement() 得到的对象)变成真正的 DOM 元素,让它们出现在屏幕上。

渲染分两种:

  1. 初次渲染
  2. 重新渲染

初次渲染是怎么发生的?

下面这段代码就是关键:

function App() {
  return <h1>Hello, React!</h1>;
}

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);

如果你建过 ReactJS 项目,去 index.jsx 里就能看到。

步骤如下:

  • App() 返回 <h1>Hello, React!</h1>
  • React 把它变成 React 元素对象。
{
  type: "h1",
  props: { children: "Hello, React!" },
}
  • React 创建一个虚拟 DOM
  • React 更新真实 DOM<h1> 被插进了 #root)。

大项目里,组件成千上万,嵌套一大堆,最后会生成一个巨大的 JS 对象树。用这个对象树造 DOM,第一次会很慢。但 React 只会在第一次全量造 DOM。

你肯定发现,第一次启动 React 项目很慢,后面再渲染就飞快。这就是"虚拟 DOM"和"协调算法"带来的优化。

虚拟 DOM 后面会详细讲!

什么是重新渲染?组件啥时候会重新渲染?

重新渲染,就是组件更新、重新执行,让 UI 跟着变化。但不是啥都触发重新渲染,React 很聪明,只有必要时才会重新渲染。

组件会在以下情况重新渲染

  1. 自己的 State 变了(useStatethis.setState 被更新)
  2. Props 变了(父组件传了新 props)
  3. 父组件重新渲染了(哪怕 props 没变)

因为 State 变了而重新渲染

组件的 stateuseState 更新时会重新渲染。

import React, { useState } from "react";

function Counter() {
  const [count, setCount] = useState(0);

  console.log("Counter Re-Rendered!");

  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
}

export default Counter;

每次点按钮,count 变了,组件就会重新渲染,UI 立马跟上。

因为 Props 变了而重新渲染

function Parent() {
  const [count, setCount] = useState(0);

  return (
    <div>
      <Child count={count} />
      <button onClick={() => setCount(count + 1)}>Update Count</button>
    </div>
  );
}


function Child({ count }) {
  console.log("Child Re-Rendered!");

  return <h1>Count: {count}</h1>;
}

export default Parent;

点按钮,count 变了,Parent 重新渲染(第一条规则),Child 也会重新渲染,因为 Child 拿了 count 作为 props。props 变了,组件就得重新渲染。

因为父组件重新渲染导致的子组件重新渲染

哪怕子组件的 props 没变,只要父组件重新渲染,子组件也会跟着重新渲染。

img

function Parent() {
  const [count, setCount] = useState(0);

  console.log("Parent Re-Rendered!");

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Re-Render Parent</button>
      <Child />
    </div>
  );
}


function Child() {
  console.log("Child Re-Rendered!");
  return <h1>Hello</h1>;
}

export default Parent;
  • Parent 组件重新渲染(state 变了)。
  • Child 组件也会重新渲染,哪怕它没接 props。

如果你不想让 Child 这种情况下也跟着重渲染,可以用 React.memo() 包一下 Child。这样只有前两种情况才会重渲染。

你可能会问,如果不用 React memo(),每次组件重渲染整个子树都跟着重渲染,岂不是卡成 PPT? 答:ReactJS 可聪明了!它有虚拟 DOM 和协调算法,后面会讲!

React 18+ Strict Mode 下的双重渲染

React 18+ 里,开发模式下 <React.StrictMode> 里的组件会渲染两次,用来检测副作用。

import React from "react";
import ReactDOM from "react-dom";

function App() {
  console.log("Component Rendered!");
  return <h1>Hello</h1>;
}

ReactDOM.createRoot(document.getElementById("root")).render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

开发模式下,你会看到控制台打印两次"Component Rendered!"。 生产环境不会这样,别慌!

认识虚拟 DOM

虚拟 DOM(V-DOM)就是 React 在内存里维护的一个轻量级的 DOM 副本。它让 React 能高效地更新 UI,而不用每次都直接怼真实 DOM。

为啥 React 要用虚拟 DOM?

  • 真实 DOM 很慢 → 直接操作性能堪忧。
  • 更新真实 DOM 很贵 → React 尽量减少没必要的改动。
  • React 会批量处理更新 → 只改该改的。

虚拟 DOM 怎么工作的?

第一步:渲染阶段(创建 V-DOM):

function App() {
  return <h1>Hello, World!</h1>;
}

React 调用 App(),生成虚拟 DOM:

{
  "type": "h1",
  "props": { "children": "Hello, World!" }
}

这个 V-DOM 不是啥真东西,就是个 JS 对象树。

第二步:Diff 阶段(新旧虚拟 DOM 对比): state/props 变了,React 会生成新的虚拟 DOM,然后和上一个虚拟 DOM 比一比。

function Counter() {
  const [count, setCount] = React.useState(0);

  return <h1>Count: {count}</h1>;
}

点按钮,count 变了,React 生成新的虚拟 DOM:

{
  "type": "h1",
  "props": { "children": "Count: 1" }
}

React 用协调算法(Reconciliation Algorithm)把新旧 V-DOM 对比一遍。

第三步:打补丁(高效更新真实 DOM)

  • React 找出不同点(diff 阶段)
  • React 只更新变了的部分,不动没变的。

举个栗子

  • 如果 <h1> 里只是文本从 "Count: 0" 变成 "Count: 1", React 只会改文本,不会整个 <h1> 都重造。

那它到底怎么改的? 答:学过 JS 操作 DOM 的都懂,document.getElementByID().innerHTML = "Count: 1",其实就是普通 JS。React 只不过用 diff 算法帮你挑出最该动的地方,最大限度减少 DOM 操作。

什么是协调(Reconciliation)?

协调就是 React 的一套算法,帮你高效地更新真实 DOM,尽量少动。

协调的步骤:

  1. state/props 变了,生成新的虚拟 DOM。
  2. 新旧 V-DOM 对比(diff)。
  3. 找出变了啥(React 的 Diff 算法)。
  4. 只更新变的部分。

React 的 Diff 算法(高效检测变化的秘诀)

  1. 规则1:元素类型变了,整个重造! 元素类型要是变了,React 直接干掉旧的,造个新的。忘了啥是 type?上面 React.createElement() 的第一个参数!
function App({ showText }) {
  return showText ? <h1>Hello</h1> : <p>Hello</p>;
}
  • <h1> 变成 <p>,React 直接删 <h1>,新造 <p>
  • 这样很慢,因为 React 不是改,是全删全造。
  • 规则2:类型一样,只改属性,飞快!

如果类型一样,React 只会改变的属性。

function App({ text }) {
  return <h1 className="title">{text}</h1>;
}
  • text 从 "Hello" 变成 "World",React 只改文本。
  • <h1> 不会重造,更新速度嗖嗖的。
  • 规则3:列表用 key,Diff 才高效!

渲染列表时,React 用 key 跟踪变化。

写得不好的(没 key)→ Diff 很低效

function List({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li>{item}</li>
      ))}
    </ul>
  );
}
  • 新加一个 item,React 会把所有 <li> 都重渲染。
  • 因为没 key,React 分不清谁是谁。

写得好的(有 key)→ Diff 超高效

function List({ items }) {
  return (
    <ul>
      {items.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}
  • 有了 key,React 能精准跟踪每个 <li>
  • 新加 item,只更新必要的元素。

key 一定要唯一,列表渲染才快!现在你明白为啥控制台老提醒你加 key 了吧?

ReactJS 的性能优化

React.memo() 记忆组件

认真看了重渲染那一节你就知道,第三种情况(子组件没接 props)其实没必要跟着重渲染。 要避免这种无用功,就用 React.memo 包一下子组件,导出时这样写:

export default React.memo(MyComponent)

这样第三种情况就不会重渲染了。前两种还是会,因为 UI 必须跟着变。

例子:

import React, { useState, memo } from "react";

const ChildComponent = memo(({ count }) => {
  console.log("Child Rendered");
  return <h2>Count: {count}</h2>;
});

function App() {
  const [count, setCount] = useState(0);
  const [value, setValue] = useState("");

  return (
    <div>
      <ChildComponent count={count} />
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <input onChange={(e) => setValue(e.target.value)} placeholder="Type here" />
    </div>
  );
}
  • 不用 React.memo()ChildComponent 每次 App 渲染都跟着重渲染,哪怕只是 value 变了。
  • 用了 React.memo(),只有 count 变了才会重渲染。

useMemo() —— 记忆计算结果

假如你有个函数,参数一来就要算半天。如果不用 useMemo(),每次调用都得重新算一遍。 比如函数接 (a, b),你传 (2, 3) 算一遍,再传 (2, 3) 还得再算。 用 useMemo(),同样参数只算一次,下次直接用缓存值。学过 DSA 的同学,这不就是 DP 吗!

例子:

import React, { useState, useMemo } from "react";

function expensiveComputation(num) {
  console.log("Computing...");
  return num * 2;
}

function App() {
  const [count, setCount] = useState(0);
  const [number, setNumber] = useState(5);

  const computedValue = useMemo(() => expensiveComputation(number), [number]);

  return (
    <div>
      <h2>Computed Value: {computedValue}</h2>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
    </div>
  );
}
  • 不用 useMemo()expensiveComputation 每次渲染都要算。
  • 用了 useMemo(),只有 number 变了才会重新算。

用 useMemo(),专治那些不需要频繁重算的昂贵计算。

useCallback() —— 记忆函数

组件重渲染时,里面的函数都会重新创建,引用也变了。 但用 useCallback() 包一下,每次渲染都用同一个函数引用,不会新建。

有两大好处:

  1. 每次重渲染不用新建函数,省时间。
  2. 如果这个函数要传给子组件,不用 useCallback(),哪怕你用 React.memo 包了子组件,子组件还是会重渲染(第二条规则,回去复习下)。 因为 props 里的函数引用变了,React 以为 props 变了。用 useCallback(),每次都是同一个引用,子组件不会白白重渲染。

例子:

import React, { useState, useCallback } from "react";
import ChildComponent from "./ChildComponent";

function App() {
  const [count, setCount] = useState(0);

  const handleClick = useCallback(() => {
    console.log("Button clicked");
  }, []);

  return (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment Count</button>
      <ChildComponent onClick={handleClick} />
    </div>
  );
}
  • 不用 useCallback()handleClick 每次渲染都新建,子组件白白重渲染。
  • 用了 useCallback(),只有依赖变了才会新建。

传函数给子组件,记得用 useCallback()!

Three.js 射线拾取原理:像素世界的侦探故事

作者 LeonGao
2025年7月7日 09:53

想象你在画廊里欣赏一幅 3D 立体画,突然想知道鼻尖正对着的是哪片云彩 —— 在数字世界里,这个动作就叫 "射线拾取"。Three.js 就像一位训练有素的侦探,能顺着你的目光(鼠标点击)在三维迷宫中找到那个被选中的物体。今天我们就来拆解这位侦探的破案手法,从像素到矩阵,揭开射线拾取的底层密码。

从屏幕点击到三维射线:坐标的跨界旅行

当你在屏幕上点击鼠标时,浏览器会告诉你一个二维坐标:(clientX, clientY)。这就像在说 "我在电影院第 5 排第 3 座",但 Three.js 需要的是 "在宇宙中哪个星球正好在这条视线延长线上"。这个转换过程,需要三场精妙的坐标变换。

首先,我们要把屏幕坐标转换成标准化设备坐标(NDC)。这一步相当于把任何尺寸的屏幕都映射到一个边长为 2 的立方体里,左下角是 (-1,-1),右上角是 (1,1)。就像无论电影院多大,都统一换算成 "从左到右占 0.3,从前到后占 0.5" 的相对位置。

// 把屏幕坐标转换为标准化设备坐标
function getNDC(clientX, clientY, canvas) {
  const rect = canvas.getBoundingClientRect();
  const x = (clientX - rect.left) / rect.width * 2 - 1;
  const y = -(clientY - rect.top) / rect.height * 2 + 1;
  return { x, y };
}

注意 y 轴的计算多了个负号,这是因为在屏幕坐标系里 y 轴向下为正,而在 Three.js 的世界里 y 轴向上为正 —— 就像两个国家用不同的行车方向,需要一个交通规则转换器。

视图矩阵与投影矩阵:三维世界的哈哈镜

现在我们有了标准化设备坐标,但这还只是在 "镜子外面" 观察。要进入三维世界,必须经过两个关键的矩阵转换:视图矩阵(Camera.matrixWorldInverse)和投影矩阵(Camera.projectionMatrix)。

视图矩阵就像你的视角转换 —— 当你歪着头看物体时,世界在你眼中会倾斜,这个矩阵记录了这种倾斜关系。投影矩阵则像哈哈镜:正交投影是平面镜,保持物体真实比例;透视投影是漏斗镜,远处的物体看起来更小。

// 创建射线投射器
const raycaster = new THREE.Raycaster();
// 更新射线:从相机位置到点击点
function updateRay(camera, mouse) {
  // 这行代码背后正在进行矩阵乘法的魔法
  raycaster.setFromCamera(mouse, camera);
}

setFromCamera方法内部正在执行一个精彩的数学舞蹈:它先将标准化设备坐标扩展成齐次坐标(x,y,1,1),然后用投影矩阵的逆矩阵乘以这个坐标,得到观察空间中的方向向量,最后再用视图矩阵的逆矩阵转换到世界空间。简单说,就是把 "镜子里的像" 还原成 "真实物体" 的位置。

射线与物体的碰撞检测:三维世界的撞球游戏

有了从相机出发的射线,接下来就是检查这条射线与哪些物体相交。这就像在漆黑的房间里挥舞一根竹竿,通过触碰感判断碰到了什么家具。

Three.js 采用的是分层检测策略,就像安检过程:

  1. 边界盒检测:先快速检查射线是否与物体的包围盒相交,排除明显不相关的物体 —— 相当于先看包裹大小是否可疑
  1. 几何体面检测:对通过第一关的物体,检查射线是否与它的任何一个三角形面相交 —— 相当于打开包裹仔细检查内容
// 检测射线与场景中物体的交点
function detectIntersections(raycaster, scene) {
  // 获取所有相交对象,按距离排序
  const intersects = raycaster.intersectObjects(scene.children, true);
  
  if (intersects.length > 0) {
    console.log(`命中目标:${intersects[0].object.name}`);
    // 最近的交点距离
    console.log(`距离:${intersects[0].distance.toFixed(2)}单位`);
  }
}

这里的intersectObjects方法会返回一个交点数组,按距离相机的远近排序。想象一下这是雷达扫描结果,最近的目标会排在最前面。每个交点对象还包含了精确的撞击位置(point)、所在的三角形索引(faceIndex)等细节,就像事故报告里记录的 "撞击点坐标北纬 30 度,东经 120 度"。

三角形相交算法:射线与平面的爱情故事

为什么 Three.js 对三角形情有独钟?因为任何复杂的 3D 模型最终都能分解成无数个三角形 —— 就像数字世界的原子。判断射线是否与三角形相交,用的是著名的 Möller-Trumbore 算法,这个算法就像月老牵线,通过计算射线与三角形是否 "有缘相会" 来判定相交。

简单来说,算法会做三件事:

  • 检查射线是否与三角形所在平面相交
  • 检查交点是否在三角形内部(通过 barycentric 坐标判断,类似 "点是否在三角形内" 的几何问题)
  • 计算交点距离射线起点的距离,用于排序

性能优化:射线拾取的高速公路

当场景中有成千上万个物体时,逐个检测会让浏览器累得气喘吁吁。Three.js 提供了几个加速技巧:

  1. 层级检测:intersectObjects的第二个参数设置为 true 时,会递归检测子物体,否则只检测顶层物体 —— 相当于只查快递外包装还是拆开所有盒子
  1. 射线范围限制:设置raycaster.near和raycaster.far可以限定检测范围,就像只在 100 米内搜索目标
  1. 空间分区:配合 Three.js 的 Octree 等空间数据结构,能大幅减少检测数量 —— 相当于在图书馆按分类查找书籍
// 优化射线检测范围
raycaster.near = 0.1; // 忽略10厘米内的物体(避免相机自己)
raycaster.far = 1000; // 只检测1000米内的物体

完整的射线拾取流程:从点击到响应

把所有步骤串联起来,就构成了完整的射线拾取流程:

// 初始化
const canvas = renderer.domElement;
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
// 监听鼠标点击
canvas.addEventListener('click', onCanvasClick);
function onCanvasClick(event) {
  // 1. 计算标准化设备坐标
  const rect = canvas.getBoundingClientRect();
  mouse.x = (event.clientX - rect.left) / rect.width * 2 - 1;
  mouse.y = -(event.clientY - rect.top) / rect.height * 2 + 1;
  
  // 2. 更新射线
  raycaster.setFromCamera(mouse, camera);
  
  // 3. 检测相交
  const intersects = raycaster.intersectObjects(scene.children, true);
  
  // 4. 处理结果
  if (intersects.length > 0) {
    // 给选中的物体添加高亮效果
    intersects[0].object.material.color.set(0xff0000);
  }
}

底层原理总结:像素侦探的工作手册

回顾整个过程,射线拾取本质上是在解决一个几何问题:在三维空间中,找到与从相机出发、穿过屏幕点击点的射线相交的物体。这个过程涉及:

  • 坐标系统的转换(屏幕坐标→标准化设备坐标→世界坐标)
  • 矩阵运算(逆矩阵用于还原世界空间)
  • 几何相交检测(射线与三角形的碰撞计算)
  • 性能优化策略(范围限制、层级检测等)

下次当你在 Three.js 场景中点击物体时,不妨想象一下这个过程:你的点击穿过层层矩阵的哈哈镜,化作一道无形的射线,在三角形构成的数字森林中穿梭,最终找到那个与你 "对视" 的物体。这就是像素世界的浪漫 —— 每一次点击,都是一次跨越维度的握手。

❌
❌