普通视图

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

从一个实战项目,看懂 `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有更深入的、更贴近实战的理解。

谢谢大家❀

深入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架构吧。

谢谢大家🌼

❌
❌