🚀React+Node全栈无死角解析,吃透文件上传的各个场景

本文涉及的产品
对象存储 OSS,20GB 3个月
对象存储 OSS,恶意文件检测 1000次 1年
云解析 DNS,旗舰版 1个月
简介: 🚀React+Node全栈无死角解析,吃透文件上传的各个场景

前言

文件上传在平时的开发过程中很经常会遇到,本文总结了如下常用的文件上传场景,包括前后端的代码实现,希望你下次遇到上传文件的场景可以直接秒杀。文章稍稍有点长,建议点赞收藏食用🐶。

  • 上传方式
  • 点击上传
  • 拖拽上传
  • 粘贴上传
  • 上传限制
  • 单、多文件上传
  • 文件夹上传
  • 上传进度
  • oss上传
  • 大文件上传
  • 切片
  • 断点续传
  • 秒传

1.png

上传方式

下面先来介绍三种常见的上传方式:

  • 点击上传
  • 拖拽上传
  • 粘贴上传

点击上传

<div onClick={() => inputRef.current.click()} className={styles.uploadWrapper}>
    点击、拖拽、粘贴文件到此处上传
    <input
      onClick={(e) => e.stopPropagation()}
      onChange={hanldeChange}
      multiple
      type="file"
      ref={inputRef}
    />
</div>

点击上传代码十分简单,就是利用input[type="file"]的能力唤起文件选择框,然后做一个自己喜欢的容器,把input框藏起来,点击容器的时候模拟input框点击即可,multiple属性是用来做多文件上传的。

拖拽上传

  const handleDrop = (event) => {
    event.preventDefault();
    const files = event.dataTransfer.files;
    uploadFiles(files);
  };

  const handleDragOver = (event) => {
    event.preventDefault();
  };
  return (
    <div className={styles.container}>
      <div
        onDrop={handleDrop}
        onDragOver={handleDragOver}
        className={styles.uploadWrapper}
        ref={uploadRef}
        onClick={() => inputRef.current.click()}
      >
        点击、拖拽、粘贴文件到此处上传
        <input onChange={hanldeChange} multiple type="file" ref={inputRef} />
      </div>
    </div>
  );

拖拽上传主要是实现了容器的drop事件,当鼠标松开时从event.dataTransfer.files获取到拖拽的文件

粘贴上传

  useEffect(() => {
    const container = uploadRef.current;
    const pasteUpload = (event) => {
      event.preventDefault();

      const items = (event.clipboardData || event.originalEvent.clipboardData)
        .items;
      let files = [];

      for (const item of items) {
        if (item.kind === "file") {
          files.push(item.getAsFile());
        }
      }
      if (files.length > 0) {
        uploadFiles(files);
      }
    };
    container.addEventListener("paste", pasteUpload);
    return () => {
      container.removeEventListener("paste", pasteUpload);
    };
  }, []);


粘贴上传的方式就是在容器中监听paste事件,把属于文件的粘贴内容过滤出来。

以上就是三种常见的上传方式,在这三种上传方式中,主要都是为了收集文件。最后上传的逻辑收口到一个uploadFiles方法中,在这个方法中可以执行一些前置的校验,比如说文件大小、文件类型、文件个数等等,校验完之后再调用后端接口进行文件上传。

上传限制 2.png

上图是一个文件对象的一些相关属性,下面需要关注的属性有:

  • name:文件名
  • size:文件大小,单位为字节,除以1024等于KB
  • type:文件类型

对于文件类型的限制,在点击上传的场景中,可以加上一个accept的属性,比如说加上一个accept="image/*",这样弹出来的文件选择框中,就只能选择图片。但是对于其余两种方式,还是得需要在代码里面进行判断。

  const uploadFiles = (files) => {
    if (files.length === 0) {
      return;
    }
    const list = Array.from(files);
    if (MAX_COUNT && list.length > MAX_COUNT) {
      message.error(`最多上传${MAX_COUNT}个文件`);
      return;
    }
    let isOverSize = false;
    if (MAX_SIZE) {
      isOverSize =
        list.filter((file) => {
          return file.size > MAX_SIZE;
        }).length > 0;
    }

    if (isOverSize) {
      message.error(`最多上传${MAX_SIZE / 1024 / 1024}M大的文件`);
      return;
    }
    let isNotMatchType = false;
    if (ACCEPS.length > 0) {
      isNotMatchType =
        list.filter((file) => {
          return ACCEPS.length > 0 && !ACCEPS.includes(file.type);
        }).length > 0;
    }

    if (isNotMatchType) {
      message.error("上传文件的类型不合法");
      return;
    }
  };

33.png 链接

开始上传

在介绍完上传文件的方式之后,就可以真正的把选中的文件发送给后端了。下面我将以Node作为服务端语言,来介绍上传文件的前后端交互全流程。

4.png


在前端代码的uploadFiles逻辑中加入以下逻辑,把我们上面收集到的文件填充到formDatafiles字段中,注意这个files字段是跟后端约定好的字段,后端根据这个字段取到文件的信息:

setLoading(true);
const formData = new FormData();
list.forEach((file) => {
  formData.append("files", file);
});
const res = await uploadApi(formData);
const data = res.data.data;
const successCount = data.filter((item) => item.success).length;
message.info(
  `上传完成,${successCount}个成功,${data.length - successCount}个失败`
);
setLoading(false);

然后后端实现我们使用express来搭建一个服务,这个服务目前需要做以下的事情:

  1. 创建一个静态目录,用于存储静态文件,可通过URL访问,使用的是express自带的static中间件
  2. 使用multer中间件,帮助我们在路由中获取文件参数
  3. 实现一个writeFile函数,将前端传过来的文件写入磁盘中

具体代码实现如下

const express = require("express");
const multer = require("multer");
const path = require("path");
const fs = require("fs");
const app = express();
const PORT = 3000;
const STATIC_PATH = path.join(__dirname, "public");
const UPLOAD_PATH = path.join(__dirname, "public/upload");
app.use(express.static(STATIC_PATH));

const upload = multer();
const writeFile = async (file) => {
  const { originalname } = file;
  return new Promise((resolve) => {
    fs.writeFile(`${UPLOAD_PATH}/${originalname}`, file.buffer, (err) => {
      if (err) {
        resolve({
          success: false,
          filePath: "",
        });
        return;
      }
      resolve({
        success: true,
        filePath: `http://localhost:3000/upload/${originalname}`,
      });
    });
  });
};
// 处理文件上传
app.post("/upload", upload.array("files"), async (req, res) => {
  // 'files'参数对应于表单中文件输入字段的名称
  const files = req.files;
  const promises = files.map((file) => writeFile(file));
  const result = await Promise.all(promises);
  // 返回上传成功的信息
  res.json({ data:result });
});

app.listen(PORT, () => {
  console.log(`Server is running on http://localhost:${PORT}`);
});

5.png

链接

上传进度

上传进度主要监听的是axios暴露的onUploadProgress事件,这个时候可以配合一个进度条使用

const res = await uploadApi(formData, {
  onUploadProgress: (progressEvent) => {
    const percentage = Math.round(
      (progressEvent.loaded * 100) / progressEvent.total
    );
    setProgress(percentage);
  },
});

这里我把网络调整成3G,可以更好的看到上传文件的进度过程: 6.png 链接

上传文件夹

拖拽/复制文件夹与点击文件夹上传稍有不同,前者需要我们自己去分析文件夹与文件的路径关系,而后者浏览器的标准接口已经帮我们处理好文件夹相关的路径信息,我们只需要稍作处理即可。下面来看具体的实现

拖拽/复制文件夹上传

先以拖拽为例,复制的逻辑与拖拽差不多。上面我们拖拽普通文件的时候是使用event.dataTransfer.files,这个api是拿不到文件夹的信息的。我们要换一个apievent.dataTransfer.items。在遍历这个数组时需要用到一个webkitGetAsEntry方法,它可以获取到文件或者文件夹的相关信息。 7.png


比如上图是一个文件夹,具体看一下需要关注的属性:

  • createReader:文件夹独有,可以递归获取文件夹下的文件夹或文件
  • isDirectory:是否为文件夹
  • isFile:是否为文件 8.png



上图是一个文件,需要关注的是

  • file:异步方法,获取文件的内容信息
  • isFile:是否为文件

这样我们就可以递归的获取文件夹,以拖拽上传为例:

  const processFiles = async (items) => {
    const folderFiles = [];

    const promises = Array.from(items).map((item) => {
      return new Promise(async (resolve) => {
        const entry = item.webkitGetAsEntry();
        if (entry.isFile) {
          await getFileFromEntry(entry, folderFiles);
        } else if (entry.isDirectory) {
          await traverseDirectory(entry, folderFiles, entry.name); // 传递文件夹名称
        }

        resolve();
      });
    });

    await Promise.all(promises);
    return folderFiles;
  };

  const getFileFromEntry = (entry, folderFiles, folderName) => {
    return new Promise((resolve) => {
      entry.file((file) => {
        if (folderName) {
          file.folder = folderName;
        }
        folderFiles.push(file);
        resolve();
      });
    });
  };

  const traverseDirectory = async (directory, folderFiles, folderName) => {
    return new Promise((resolve) => {
      const reader = directory.createReader();
      reader.readEntries(async (entries) => {
        const entryPromises = entries.map((entry) => {
          return new Promise(async (entryResolve) => {
            if (entry.isFile) {
              await getFileFromEntry(entry, folderFiles, folderName);
            } else if (entry.isDirectory) {
              await traverseDirectory(
                entry,
                folderFiles,
                `${folderName}#${entry.name}`
              );
            }
            entryResolve();
          });
        });

        await Promise.all(entryPromises);
        resolve();
      });
    });
  };

  const handleDrop = async (event) => {
    event.preventDefault();

    const items = event.dataTransfer.items;
    const files = await processFiles(items);

    uploadFiles(files);
  };

解释一下上面的流程:

  • 首先判断是文件还是文件夹,是文件的话,则调用file方法拿到文件内容;是文件夹的话则调用createReader来读取文件夹下面的信息
  • 递归过程中需要把文件夹的名称手动拼成一个路径
  • 这里注意我们使用#来作为文件路径之间的分割符,因为尝试了一下如果使用/,后端会接收不到
  • 并在读文件的时候,给文件对象赋予一个folder属性

然后来改造一下上传文件的逻辑

const buildFile = (file) => {
  if (file.folder) {
    const originalFile = file;
    const fileName = originalFile.name;
    const newFileName = `${file.folder}#${encodeURIComponent(fileName)}`;
    const newFile = new File([originalFile], newFileName, {
      type: originalFile.type,
      lastModified: originalFile.lastModified,
    });
    return newFile;
  }
  return null;
};

list.forEach((file) => {
  let newFile = buildFile(file);

  formData.append("files", newFile ? newFile : file);
});

上传之前预处理文件,如果文件中存在folder属性,则把文件夹的信息拼在文件名中,因为file.name是一个只读属性,无法修改,所以这里需要拷贝一个文件,赋予新的文件名。这里注意文件名称中可以存在#字符,所以需要使用encodeURIComponent转一下。

复制的逻辑跟拖拽的处理逻辑大同小异,只有前面处理粘贴板的逻辑是不一样的:

const pasteUpload = async (event) => {
  event.preventDefault();

  const items = (event.clipboardData || event.originalEvent.clipboardData)
    .items;
  const fileItems = Array.from(items).filter(
    (item) => item.kind === "file"
  );
  const files = await processFiles(fileItems);
  if (files.length > 0) {
    uploadFiles(files);
  }
};

点击文件夹上传

点击上传的时候,文件夹跟文件是不可以同时上传的,拖拽/复制的时候是可以的。所以点击文件夹上传的时候需要区分开来

<Button onClick={() => folderInputRef.current.click()} type="primary">
  上传文件夹
</Button>
<input
  className={styles.hide}
  directory=""
  webkitdirectory=""
  onClick={(e) => e.stopPropagation()}
  onChange={handleFolderChange}
  multiple
  type="file"
  ref={folderInputRef}
/>

所以这里我另外做了一个按钮来实现点击文件夹的上传。 9.png

可以看到在点击上传文件夹的时候会有一个webkitRelativePath属性,这个就是包含了文件的所有路径信息。所以我们只需要稍作处理,就可以直接调用uploadFiles

  const handleFolderChange = (e) => {
    const list = Array.from(e.target.files);
    const files = list.map((file) => {
      if (file.webkitRelativePath) {
        const path = file.webkitRelativePath.split("/");
        const folders = path.slice(0, -1);
        file.folder = folders.join("#");
      }
      return file;
    });
    if (files.length > 0) {
      uploadFiles(files);
    }
    folderInputRef.current.value = "";
  };


后端实现

好的,上面就是前端部分的实现方式,下面我们来看后端的实现方式。后端要改造的点有如下几点:

  • 给定一个key,表示这次的上传动作,给文件夹/文件起一个唯一名称
  • 如果文件名中存在#,则认为该文件是处于某个文件夹下的,需要先创建好文件夹再写文件

具体代码如下:

const writeFile = async (file, key) => {
  const { originalname } = file;
  /**组装文件的唯一名称 */
  const fileName = getFileName(originalname);
  /**组装文件夹的唯一名称 */
  const folders = originalname
    .split("#")
    .slice(0, -1)
    .map((item) => `${item}-${key}`);
  let path = `${UPLOAD_PATH}/${fileName}`;
  /**前端读取的路径 */
  let resPath = `${fileName}`;
  let folderFormat = [];
  for (let i = 0; i < folders.length; i++) {
    const folderName = folders.slice(0, i + 1).join("/");
    folderFormat.push(folderName);
  }
  const folderName = folderFormat[folderFormat.length - 1];
  /**如果存在文件夹信息 */
  if (folderFormat.length > 0) {
    /**创建文件夹 */
    if (!fs.existsSync(`${UPLOAD_PATH}/${folderName}`)) {
      fs.mkdirSync(`${UPLOAD_PATH}/${folderName}`);
      path = `${UPLOAD_PATH}/${folderName}/${fileName}`;
      resPath = `${folderName}/${fileName}`;
    }
  }

  return new Promise((resolve) => {
    fs.writeFile(path, file.buffer, (err) => {
      if (err) {
        resolve({
          success: false,
          filePath: "",
        });
        return;
      }
      resolve({
        success: true,
        filePath: `http://localhost:3000/upload/${resPath}`,
      });
    });
  });
};

11.png 链接


上传至OSS

在这个上云的时代,很少会直接把文件写在文件系统里面了,因为容器一重启文件就会丢,除非挂载了额外的磁盘路径。大多数还是把文件上传到对象存储服务里边,这里我以阿里云的oss为例,把我们的文件从磁盘上传到对象存储。

const OSS = require("ali-oss");
const client = new OSS({
  region: 'your-oss-region',
  accessKeyId: 'your-access-key-id',
  accessKeySecret: 'your-access-key-secret',
  bucket: 'your-bucket-name'
});

fs.writeFile(path, file.buffer, async (err) => {
  const res = await client.put(resPath, path);
  if (err) {
    resolve({
      success: false,
      filePath: "",
    });
    return;
  }
  resolve({
    success: true,
    filePath: res.url,
  });
});

写入文件后调用client.put方法就可以把资源传输到oss中,其中resPath是阿里云oss的存储地址,path是文件的本地地址。 12.png

大文件上传

下面我们来讨论大文件上传,主要有分片上传,秒传,断点续传等。

  • 分片上传:上传大文件时,如果整个文件一次性上传,网络故障或其他中断可能导致整个上传过程失败,用户需要重新上传整个文件。分片上传允许将文件拆分成小块,每个小块独立上传,如果其中一个小块上传失败,只需重新上传该小块,而不是整个文件。
  • 秒传:如果该文件已经上传过,则直接返回成功
  • 断点续传:只上传还没有上传过的文件片段

下面以单文件上传为例,讨论上面的三个功能

分片上传

先介绍一个分片上传的一整个流程:

  1. 前端将文件按照一定的大小规则进行切片
  2. 前端算出文件的md5,这个md5会一直作为文件的唯一id标识,用这个md5向后端换一个uploadId
  3. 前端拿到这个uploadId之后向后端传输所有分片
  4. 所有分片传输完之后发起合并分片请求
  5. 合并完成,上传结束

前端实现

这里我定义了1M大小一个分片,通过SparkMD5去计算文件的MD5,然后通过file.slice方法对文件进行切片,最后开始发起上传请求。

先用md5换取一个uploadId,随后把所有的分片发送过去,最后发送合并请求。

  import SparkMD5 from "spark-md5";
  //....
  const calculateMD5 = (file) => {
    return new Promise((resolve) => {
      const reader = new FileReader();

      reader.onload = (e) => {
        const spark = new SparkMD5.ArrayBuffer();
        spark.append(e.target.result);
        const md5 = spark.end();
        resolve(md5);
      };

      reader.onerror = (error) => {
        console.error(error);
      };

      reader.readAsArrayBuffer(file);
    });
  };

  const getFileExtension = (file) => {
    const fileName = file.name;
    const dotIndex = fileName.lastIndexOf(".");

    if (dotIndex !== -1) {
      return fileName.substring(dotIndex + 1).toLowerCase();
    }

    return null; // No file extension found
  };
  const CHUNK_SIZE = 1 * 1024 * 1024;
  const uploadBigFile = async (file) => {
    const md5 = await calculateMD5(file);
    const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
    const fileName = `${md5}.${getFileExtension(file)}`;
    const res = await initUpload({
      fileName,
      fileMD5: md5,
      totalChunks,
    });
    const uploadId = res.data.uploadId;
    const promises = [];
    for (let chunkNumber = 1; chunkNumber <= totalChunks; chunkNumber++) {
      const start = (chunkNumber - 1) * CHUNK_SIZE;
      const end = Math.min(chunkNumber * CHUNK_SIZE, file.size);

      const chunk = file.slice(start, end);
      const formData = new FormData();
      formData.append("file", chunk);
      formData.append("fileName", fileName);
      formData.append("uploadId", uploadId);
      formData.append("partNumber", chunkNumber);
      formData.append("fileMD5", md5);
      promises.push(uploadPart(formData));
    }
    await Promise.all(promises);
    await completeUpload({
      uploadId,
      fileMD5: md5,
      fileName,
    });

对于大文件的md5计算,可以有以下的拓展思考,本文就不再展开

  • 把计算逻辑放到web worker,不要阻塞主线程
  • rust等语言实现wasm放到前端使用,可以加速md5的计算过程

后端实现

后端需要实现三个接口:

  1. 初始化上传任务,返回uploadId
  2. 接收各个分片,上传到oss
  3. 所有分片上传完之后,向oss发起合并请求
const fileMap = {};
app.post("/initUpload", async (req, res) => {
  const { fileMD5, fileName, totalChunks } = req.body;
  const result = await client.initMultipartUpload(fileName);
  const uploadId = result.uploadId;
  fileMap[fileMD5] = {
    md5: fileMD5,
    uploadId,
    totalChunks,
    uploadedChunks: [],
    parts: [],
    url: "",
  };
  res.json({ uploadId });
});

app.post("/uploadPart", upload.array("file"), async (req, res) => {
  const { fileName, uploadId, partNumber, fileMD5 } = req.body;
  if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) {
    res.json({ success: true });
    return;
  }
  try {
    const partResult = await client.uploadPart(
      fileName,
      uploadId,
      partNumber,
      req.files[0].buffer
    );
    fileMap[fileMD5].uploadedChunks.push(partNumber);
    fileMap[fileMD5].parts.push({
      number: partNumber,
      etag: partResult.etag,
    });
    res.json({ success: true });
  } catch (error) {
    res.status(500).json({ error: "上传失败" });
  }
});

app.post("/completeUpload", async (req, res) => {
  const { fileName, uploadId, fileMD5 } = req.body;
  try {
    const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number);
    const completeResult = await client.completeMultipartUpload(
      fileName,
      uploadId,
      parts
    );
    res.json({ completeResult });
  } catch (error) {
    res.status(500).json({ error: "上传失败" });
  }
});

13.png 链接

这里再介绍一下上面定义的fileMap对象,这个对象主要用来记录一些大文件上传的相关信息,用于做后面的秒传和断点续传。

  • md5:文件的md5
  • uploadIdoss上传的uploadId
  • totalChunks:一共分多少片
  • uploadedChunks:目前已经上传的chunk下标
  • parts:目前上传的文件部分
  • url:上传完成的URL

秒传

秒传就是对于已经上传过的文件立马返回上传成功以及上传后的链接,这样前端就不用再走分片上传合并的逻辑。

只需要改造一下initUpload接口以及completeUpload接口,在initUpload的时候如果能在fileMap中拿到url就直接返回url,前端拿到url之后就不走分片上传逻辑;completeUpload合并完成之后把url填入fileMap中。

app.post("/initUpload", async (req, res) => {
  const { fileMD5, fileName, totalChunks } = req.body;
  let uploadId;
  let url;
  if (!fileMap[fileMD5]) {
    const result = await client.initMultipartUpload(fileName);
    uploadId = result.uploadId;
    fileMap[fileMD5] = {
      md5: fileMD5,
      uploadId,
      totalChunks,
      uploadedChunks: [],
      parts: [],
      url: null,
    };
  } else {
    uploadId = fileMap[fileMD5].uploadId;
    if (fileMap[fileMD5].url) {
      url = fileMap[fileMD5].url;
    }
  }
  res.json({ uploadId, url });
});

app.post("/completeUpload", async (req, res) => {
  const { fileName, uploadId, fileMD5 } = req.body;
  try {
    const parts = fileMap[fileMD5].parts.sort((a, b) => a.number - b.number);
    const completeResult = await client.completeMultipartUpload(
      fileName,
      uploadId,
      parts
    );
    const url = completeResult.res.requestUrls[0];
    fileMap[fileMD5].url = url;
    res.json({ url });
  } catch (error) {
    res.status(500).json({ error: "上传失败" });
  }
});

15.png 链接

断点续传

断点续传的逻辑就是不需要再次上传已经传过的片段,主要改造一下uploadPart接口。如果当前分片可以在fileMap中找到,则直接返回。

if (fileMap[fileMD5].uploadedChunks.includes(partNumber)) {
    res.json({ success: true });
    return;
}

16.png 链接



最后

以上就是本文介绍的所有场景,如果你有一些不同的想法,欢迎评论区交流~如果你觉得有所收获的话,点点关注点点赞吧~


相关文章
|
29天前
|
前端开发 JavaScript
React Hooks 全面解析
【10月更文挑战第11天】React Hooks 是 React 16.8 引入的新特性,允许在函数组件中使用状态和其他 React 特性,简化了状态管理和生命周期管理。本文从基础概念入手,详细介绍了 `useState` 和 `useEffect` 的用法,探讨了常见问题和易错点,并提供了代码示例。通过学习本文,你将更好地理解和使用 Hooks,提升开发效率。
64 4
|
1月前
|
前端开发
深入解析React Hooks:构建高效且可维护的前端应用
本文将带你走进React Hooks的世界,探索这一革新特性如何改变我们构建React组件的方式。通过分析Hooks的核心概念、使用方法和最佳实践,文章旨在帮助你充分利用Hooks来提高开发效率,编写更简洁、更可维护的前端代码。我们将通过实际代码示例,深入了解useState、useEffect等常用Hooks的内部工作原理,并探讨如何自定义Hooks以复用逻辑。
|
1月前
|
人工智能 自然语言处理 前端开发
SpringBoot + 通义千问 + 自定义React组件:支持EventStream数据解析的技术实践
【10月更文挑战第7天】在现代Web开发中,集成多种技术栈以实现复杂的功能需求已成为常态。本文将详细介绍如何使用SpringBoot作为后端框架,结合阿里巴巴的通义千问(一个强大的自然语言处理服务),并通过自定义React组件来支持服务器发送事件(SSE, Server-Sent Events)的EventStream数据解析。这一组合不仅能够实现高效的实时通信,还能利用AI技术提升用户体验。
162 2
|
24天前
|
缓存 前端开发 JavaScript
前端的全栈之路Meteor篇(二):容器化开发环境下的meteor工程架构解析
本文详细介绍了使用Docker创建Meteor项目的准备工作与步骤,解析了容器化Meteor项目的目录结构,包括工程准备、环境配置、容器启动及项目架构分析。提供了最佳实践建议,适合初学者参考学习。项目代码已托管至GitCode,方便读者实践与交流。
|
1月前
|
JavaScript 前端开发 算法
React 虚拟 DOM 深度解析
【10月更文挑战第5天】本文深入解析了 React 虚拟 DOM 的工作原理,包括其基础概念、优点与缺点,以及 Diff 算法的关键点。同时,分享了常见问题及解决方法,并介绍了作者在代码/项目上的成就和经验,如大型电商平台的前端重构和开源贡献。
55 3
|
1月前
|
消息中间件 JavaScript 前端开发
用于全栈数据流的 JavaScript、Node.js 和 Apache Kafka
用于全栈数据流的 JavaScript、Node.js 和 Apache Kafka
43 1
|
30天前
|
缓存 资源调度 JavaScript
npx与npm的差异解析,以及包管理器yarn与Node版本管理工具nvm的使用方法详解
npx与npm的差异解析,以及包管理器yarn与Node版本管理工具nvm的使用方法详解
31 0
|
30天前
|
前端开发 JavaScript 程序员
【从前端入门到全栈】Node.js 之核心概念
【从前端入门到全栈】Node.js 之核心概念
|
3月前
|
前端开发 Java UED
JSF 面向组件开发究竟藏着何种奥秘?带你探寻可复用 UI 组件设计的神秘之路
【8月更文挑战第31天】在现代软件开发中,高效与可维护性至关重要。JavaServer Faces(JSF)框架通过其面向组件的开发模式,提供了构建复杂用户界面的强大工具,特别适用于设计可复用的 UI 组件。通过合理设计组件的功能与外观,可以显著提高开发效率并降低维护成本。本文以一个具体的 `MessageComponent` 示例展示了如何创建可复用的 JSF 组件,并介绍了如何在 JSF 页面中使用这些组件。结合其他技术如 PrimeFaces 和 Bootstrap,可以进一步丰富组件库,提升用户体验。
55 0
|
3月前
|
开发者 安全 UED
JSF事件监听器:解锁动态界面的秘密武器,你真的知道如何驾驭它吗?
【8月更文挑战第31天】在构建动态用户界面时,事件监听器是实现组件间通信和响应用户操作的关键机制。JavaServer Faces (JSF) 提供了完整的事件模型,通过自定义事件监听器扩展组件行为。本文详细介绍如何在 JSF 应用中创建和使用事件监听器,提升应用的交互性和响应能力。
37 0

推荐镜像

更多