阅读视图

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

模仿ai数据流 开箱即用

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>AI 流式输出 + Markdown渲染</title>
  <style>
    body { max-width: 800px; margin: 20px auto; padding: 0 20px; }
    #result {
      white-space: pre-wrap;
      border: 1px solid #eee;
      padding: 16px;
      min-height: 200px;
      margin-top: 20px;
      line-height: 1.6;
    }
    #result h1, #result h2, #result h3 { margin: 10px 0; }
    #result strong { color: #007bff; }
    #result code { background: #f4f4f4; padding: 2px 4px; border-radius: 4px; }
    #result pre { background: #f4f4f4; padding: 10px; overflow-x: auto; }
    #btn { padding: 10px 20px; font-size: 16px; cursor: pointer; }
  </style>
</head>
<body>
  <h3>AI 流式输出演示(渲染Markdown)</h3>
  <button id="btn">开始提问:介绍一下JavaScript</button>
  <div id="result"></div>

  <!-- 👇 加这一行:引入 Markdown 渲染库(和豆包用的一样) -->
  <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>

  <script>
    const btn = document.getElementById('btn');
    const result = document.getElementById('result');

    // 👇 用来存完整的回答文本
    let fullText = '';

    btn.onclick = async () => {
      btn.disabled = true;
      btn.innerText = 'AI 正在流式输出...';
      result.innerText = '';
      fullText = '';

      try {
        const response = await fetch("https://open.bigmodel.cn/api/paas/v4/chat/completions", {
          method: "POST",
          headers: {
            "Content-Type": "application/json",
            "Authorization": "96f0813aca214bb486892a55f7148622.oQFhjTVnwDHvmnEC",
          },
          body: JSON.stringify({
            model: "glm-4-flash",
            messages: [{ role: "user", content: "介绍一下JavaScript" }],
            stream: true
          })
        });

        const reader = response.body.getReader();
        const decoder = new TextDecoder();

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const chunk = decoder.decode(value, { stream: true });
          const lines = chunk.split("\n").filter(i => i);

          for (let line of lines) {
            if (line.startsWith("data: ")) {
              const jsonStr = line.replace("data: ", "");
              if (jsonStr === "[DONE]") continue;

              try {
                const data = JSON.parse(jsonStr);
                const text = data.choices[0]?.delta?.content || "";

                // 👇 拼接完整文本
                fullText += text;

                // 👇 关键:把 Markdown 渲染成 HTML(豆包就是这么做的!)
                result.innerHTML = marked.parse(fullText);

              } catch (e) {}
            }
          }
        }
      } catch (err) {
        result.innerText = "错误:" + err.message;
      } finally {
        btn.innerText = "重新提问";
        btn.disabled = false;
      }
    };
  </script>
</body>
</html>


总结:

  • fetch 发请求 → stream: true

  • reader.read() 接收二进制流

  • 转字符串 → 按行拆分

  • data: 后面的 JSON

  • choices[0].delta.content 拼文字 → 渲染页面

备注:

Markdown:带「排版标记」的纯文本字符串

直接复制到.html,然后到open.bigmodel.cn/apikey/plat… 平台拿取一个api的key值

这段代码,就是豆包 /chat/completion 接口的工作方式 + 渲染方式

微服务-乾坤

乾坤:

目标是将庞大的单体前端应用,拆解成多个可独立开发、部署、运行的小型应用(微应用),并最终无缝集成在一起

  • 主应用(基座)

    • 负责注册、加载、卸载子应用。
    • 提供公共布局、登录、全局样式、全局状态。
  • 子应用(微应用)

    • 一个完整的业务模块(如:商品、订单、用户中心)。
    • 暴露固定生命周期钩子,供主应用调用

实操:

一、主应用

1、main.js 注册子应用

import { registerMicroApps, start } from 'qiankun';
const props = {
  getMainData: () => store.state.globalState.mainData,
  updateMainData:(child)=>{
    store.commit('SET_GLOBAL_STATE',child)
  }
}
registerMicroApps([
{
  name: 'vue app',
  entry: '//localhost:8000',
  container: '#childContainer',
  activeRule: '/vue',
  props
},
{
  name: 'react app', // app name registered
  entry: '//localhost:9000',
  container: '#childContainer',
  activeRule: '/react',
  props
}
]);
start({
sandbox: {
  strictStyleIsolation: true, // 严格样式隔离(推荐),主子应用样式隔离
  // experimentalStyleIsolation: true // 可选:追求兼容(弹窗、UI 库正常)
}
})

2、App.vue (任意组件,存放子应用容器)

<template>
  <div id="app">
    <nav>
      <router-link to="/">Home</router-link> |
      <router-link to="/vue">跳转子应用vue</router-link> |
      <router-link to="/react">跳转子应用react</router-link> |
    </nav>
    <div>
      <h1>主应用data</h1>
      <h2 style="color:red">name:{{ name}}</h2>
    </div>
    <router-view/>
    <hr>
    <div id="childContainer"></div> // 存放子应用容器
  </div>
</template>

<script>
import { mapState } from 'vuex'
export default {
  computed: {
    ...mapState(['globalState']),
    name(){
      return this.globalState?.mainData?.userInfo?.name||''
    }
  }
}
</script>
<style lang="scss">
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
}

nav {
  padding: 30px;
}
.childContainer{
  display: flex;
  justify-content: center;
}
</style>

二、子应用-vue

1、main.js

// 定义变量存储 Vue 实例
let instance = null

// 渲染函数
function render(props = {}) {
  const { container } = props

  instance = new Vue({
    router,
    store,
    render: h => h(App)
  // 乾坤会把容器传给你,避免挂载到主应用根节点(不污染主应用节点)
  }).$mount(container ? container.querySelector('#app') : '#app')
}

// 独立运行时(非微应用环境)直接渲染
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  // console.log('[vue] 微应用初始化')
}
export async function mount(props) { // props主应用传递的公共数据
  store.commit('SET_GLOBAL_STATE',{
    ...props,
    mainData:props?.getMainData()||{}
  })
  render(props)
}

export async function unmount() {
  console.log('[vue2] 微应用卸载')
  instance.$destroy() // 销毁实例
  instance.$el.innerHTML = '' // 清空 DOM
  instance = null
}

说明:为什么主应用传递给子应用时,子应用能拿到 container.querySelector('#app') ,主应用时如何能识别到的?

当子应用被主应用加载时,qiankun 会自动做这一步

  1. 去请求子应用的 index.html
  2. 解析子应用 HTML,隔离后挂载到主应用容器(包括里面的 <div id="app"></div>
  3. 子应用的根节点 #app 渲染到主应用的 #childContainer 容器中

2、vue.config.js(子应用能被主应用识别加载)

const { defineConfig } = require('@vue/cli-service')
const { name } = require('./package.json')

module.exports = defineConfig({
  // 微应用唯一名称(主应用注册时要一致)
  configureWebpack: {
    output: {
      library: `${name}-[name]`, // 主应用上name呼应
      libraryTarget: 'umd', // 把微应用打包成 umd 格式,让子应用变成“可被主应用加载的格式”
      chunkLoadingGlobal: `webpackJsonp_${name}`,
    },
  },
  transpileDependencies: true,
  devServer: {
    port: 8000, // 自己定义微应用端口
    headers: {
      'Access-Control-Allow-Origin': '*', // 允许跨域(乾坤必须)
    },
  },
})

3、router/index.js

const router = new VueRouter({
  mode: 'history',
  base: window.__POWERED_BY_QIANKUN__ ? '/vue' : process.env.BASE_URL, // vue 是主应用配置的 activeRule
  routes
})

说明:为什么vue不需要改publicPath?

  • Vue CLI 项目默认 publicPath: '/'

  • 被 qiankun 加载时,会自动修正子应用静态资源路径

  • 子应用部署到非根目录时必须改,不是永远不用改

三、子应用-react

1、index.js

let instance = null

function render(props = {}) {
  const { container } = props
  const domContainer = container
    ? container.querySelector('#root')
    : document.getElementById('root')

  instance = ReactDOM.createRoot(domContainer)

  instance.render(
    <React.StrictMode>
      {/* 必须包 Provider */}
      <Provider store={store}>
        <RouterProvider router={router} />
      </Provider>
    </React.StrictMode>
  )
}
// 独立运行
if (!window.__POWERED_BY_QIANKUN__) {
  render()
}

export async function bootstrap() {
  console.log('[react] 微应用初始化')
}

export async function mount(props) {
  store.dispatch({
    type: "SET_GLOBAL_STATE",
    payload: {
      ...props,
      mainData: props?.getMainData?.() || {}
    }
  })
  render(props)
}

export async function unmount() {
  if (instance) {
    instance.unmount()
    instance = null
  }
}
reportWebVitals();

2、craco.config.js

react脚手架默认是这样的:

  • 所有 webpack、babel、eslint 配置全部藏在 node_modules 里
  • 你看不到、改不了
  • 你的项目很干净,只有 src、public

官方eject方法:

  • 不可逆:一旦执行,再也回不去

    • 把所有隐藏的配置文件,一次性全部复制到你的项目里
    • 这个命令会被删掉,再也不能执行第二次,也不能撤销!
  • 暴露几百个配置文件,你必须自己维护所有依赖和更新

    • 暴露几百个配置文件,必须自己维护依赖
  • 失去 CRA 后续升级能力

    • CRA 官方会不断更新,但eject就没有了(比如:优化打包速度、修复安全漏洞、升级 webpack、升级 babel、升级 eslint、加新特性等)

craco不用 eject,也能改 webpack 配置:

const { name } = require('./package.json')

module.exports = {
  webpack: {
    configure: (config) => {
      config.output.library = `${name}-[name]`
      config.output.libraryTarget = 'umd'
      config.output.chunkLoadingGlobal = `webpackJsonp_${name}`
      config.output.publicPath = process.env.NODE_ENV === 'development'
    ? 'http://localhost:9000/' 
    : '/'; // 方便引入静态资源不会404
    return config
    }
  },
  devServer: (config) => {
    config.headers = {
      'Access-Control-Allow-Origin': '*'
    }
    return config
  }
}

3、router/index

import { createBrowserRouter } from 'react-router-dom'
import App from "../App.js"

// 👇 核心:微应用必须加这个 base
const base = window.__POWERED_BY_QIANKUN__ ? '/react' : '/'

const router = createBrowserRouter([
  {
    path: '/',
    element:<App />
  }
], {
  basename: base  // 👈 这里注入 base
})

export default router

四、主、子通信

1、vuex+props

  • 将公共数据、更新公共数据方法存储到vuex
  • 通过注册应用registerMicroApps中props传递给子数据
    const props = {
      getMainData: () => store.state.globalState.mainData,
      updateMainData:(child)=>{
        store.commit('SET_GLOBAL_STATE',child)
      }
    }
     registerMicroApps([
        {
          name: 'vue app',
          entry: '//localhost:8000',
          container: '#childContainer',
          activeRule: '/vue',
          props
        }
      ]);
    
  • 子应用通过周期函数mount获取props再另行存储

2、initGlobalState、setGlobalState、onGlobalStateChange

  • initGlobalState(数据初始化)
  • setGlobalState(更新数据)
  • onGlobalStateChange(监听数据变化)
// qiankun/index.js
import { initGlobalState } from 'qiankun';

const initialState = {
  userInfo: {},
  token:''
}

// 生成 actions
const actions = initGlobalState(initialState)

// 监听全局变化(可选)
actions.onGlobalStateChange((state) => {
  console.log('主应用全局状态变化:', state)
})
export { actions }    
// main.js
import "./qiankun"
// 组件内使用
import { actions } from '@/qiankun/index.js'

onChangeGlobal(){
  actions.setGlobalState({token:`token_update_----`})
}
// 子应用中使用
// 子应用通过props接收,方法都在props上可以直接调用
props.setGlobalState({token:'00000000000000000000'})

五、子、子通信

需要主应用做中转

  • initGlobalState主应用
  • 子应用A:setGlobalState
  • 子应用B:onGlobalStateChange监听获取

总结:

主应用、子应用相连:

1、主应用做什么

  • 注册子应用(registerMicroApps

  • 启动 qiankun(start

  • 提供子应用挂载容器(<div id="container"></div>

  • 通过 activeRule 路由规则匹配子应用

2、子应用做什么

  • 子应用在主应用提供的容器内进行渲染

  • 导出生命周期函数bootstrap/mount/unmount

  • 配置 webpack 打包为 umd 格式(让主应用能识别)

    • library
    • libraryTarget: 'umd'
    • chunkLoadingGlobal
  • 配置跨域devServer.headers

  • 配置路由 base(与主应用 activeRule 对应)

  • 配置 publicPath(防止静态资源 404)

    • React 必须配
    • Vue 可配可不配(建议配)

大文件上传-spark-md5

概述:后端服务(Node + Express)、前端(vue+spark-md5)

一、后端服务

1、创建后端项目

mkdir upload-server
cd upload-server
npm init -y
npm install express cors multer fs-extra

2、 后端完整代码 server.js

const express = require('express');
const cors = require('cors');
const multer = require('multer');
const fse = require('fs-extra');
const path = require('path');

const app = express();
// 解决跨域 + 大文件请求体限制
app.use(cors());
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));

// 配置(和前端完全一致)
const UPLOAD_DIR = path.resolve(__dirname, 'upload'); // 分片临时目录
const MERGE_DIR = path.resolve(__dirname, 'merged');   // 合并后文件目录
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 分片

// 确保目录存在(启动时就创建,避免运行时创建失败)
fse.ensureDirSync(UPLOAD_DIR);
fse.ensureDirSync(MERGE_DIR);

// ✅ 修复1:multer 配置,不再从 req.body 取参数,改用动态存储
const storage = multer.memoryStorage(); // 改用内存存储,避免目录创建时序问题
const upload = multer({ 
  storage,
  limits: { fileSize: 5 * 1024 * 1024 } // 限制分片大小,和前端一致
});

/**
 * 1. 查询已上传的分片(断点续传/秒传核心)
 */
app.post('/checkfile', async (req, res) => {
  try {
    const { fileHash, fileName } = req.body;
    if (!fileHash || !fileName) {
      return res.status(400).json({ code: -1, msg: '参数缺失' });
    }

    const ext = path.extname(fileName);
    const filePath = path.resolve(MERGE_DIR, `${fileHash}${ext}`);
    
    // 秒传:文件已存在
    if (fse.existsSync(filePath)) {
      return res.json({ code: 0, uploadedChunks: [], shouldUpload: false });
    }

    // 读取已上传分片
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
    let uploadedChunks = [];
    if (fse.existsSync(chunkDir)) {
      uploadedChunks = await fse.readdir(chunkDir);
    }
    res.json({ code: 0, uploadedChunks, shouldUpload: true });
  } catch (error) {
    console.error('checkfile 错误:', error);
    res.status(500).json({ code: -1, msg: '服务器错误' });
  }
});

/**
 * 2. 上传分片(修复核心:手动处理存储,避免 multer 时序问题)
 */
app.post('/uploadchunk', upload.single('chunk'), async (req, res) => {
  try {
    const { fileHash, chunkIndex } = req.body;
    const chunk = req.file; // multer 解析后的文件 buffer

    if (!fileHash || chunkIndex === undefined || !chunk) {
      return res.status(400).json({ code: -1, msg: '参数缺失' });
    }

    // 手动创建分片目录(确保存在)
    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
    await fse.ensureDir(chunkDir);

    // 手动写入分片文件
    const chunkPath = path.resolve(chunkDir, chunkIndex.toString());
    await fse.writeFile(chunkPath, chunk.buffer);

    res.json({ code: 0, msg: '分片上传成功' });
  } catch (error) {
    console.error('uploadchunk 错误:', error);
    res.status(500).json({ code: -1, msg: '分片上传失败', error: error.message });
  }
});

/**
 * 3. 合并所有分片
 */
app.post('/mergefile', async (req, res) => {
  try {
    const { fileHash, fileName, chunkCount } = req.body;
    if (!fileHash || !fileName || !chunkCount) {
      return res.status(400).json({ code: -1, msg: '参数缺失' });
    }

    const chunkDir = path.resolve(UPLOAD_DIR, fileHash);
    const ext = path.extname(fileName);
    const filePath = path.resolve(MERGE_DIR, `${fileHash}${ext}`);

    // 检查分片目录是否存在
    if (!fse.existsSync(chunkDir)) {
      return res.status(400).json({ code: -1, msg: '分片目录不存在' });
    }

    // 按顺序合并分片
    const writeStream = fse.createWriteStream(filePath);
    for (let i = 0; i < chunkCount; i++) {
      const chunkPath = path.resolve(chunkDir, i.toString());
      // 检查分片是否存在
      if (!fse.existsSync(chunkPath)) {
        return res.status(400).json({ code: -1, msg: `分片 ${i} 缺失` });
      }
      const readStream = fse.createReadStream(chunkPath);
      await new Promise((resolve, reject) => {
        readStream.pipe(writeStream, { end: false });
        readStream.on('end', resolve);
        readStream.on('error', reject);
      });
    }

    // 关闭写入流
    writeStream.end();

    // 合并完成删除分片目录
    await fse.remove(chunkDir);
    res.json({ code: 0, msg: '文件合并成功', url: `/merged/${fileHash}${ext}` });
  } catch (error) {
    console.error('mergefile 错误:', error);
    res.status(500).json({ code: -1, msg: '合并失败', error: error.message });
  }
});

// 静态资源访问合并后的文件
app.use('/merged', express.static(MERGE_DIR));

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`后端服务启动成功:http://localhost:${PORT}`);
  console.log(`分片存储目录:${UPLOAD_DIR}`);
  console.log(`合并后文件目录:${MERGE_DIR}`);
});

3、启动后端:

node server.js

二、前端服务

1、.vue文件

<template>
  <div id="app" style="max-width: 800px;margin: 50px auto;">
    <h2>Vue2 大文件分片上传(断点续传)</h2>
    <input type="file" @change="handleFileChange">
    <button :disabled="!file || uploading" style="margin-left: 10px;" @click="handleUpload">
      {{ uploading ? '上传中...' : '开始上传' }}
    </button>

    <!-- 进度条(已修复动态样式) -->
    <div v-if="totalProgress > 0" style="margin-top: 20px;">
      <div>总进度:{{ totalProgress.toFixed(2) }}%</div>
      <div style="height:5px;background:#eee;border-radius:3px;">
        <div
          :style="{
            height: '100%',
            background: '#42b983',
            width: totalProgress + '%',
            transition: '0.3s'
          }"
        />
      </div>
    </div>

    <div style="margin-top: 20px;color: #333;">
      <p v-if="uploadedChunkList.length">已上传分片:{{ uploadedChunkList.join(',') }}</p>
      <p v-if="msg" :style="{color: msg.includes('成功') ? 'green' : 'red'}">{{ msg }}</p>
    </div>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5'
import axios from 'axios'

export default {
  name: 'App',
  data() {
    return {
      file: null,
      fileHash: '',
      CHUNK_SIZE: 2 * 1024 * 1024, // 2MB 分片
      chunkList: [],
      uploadedChunkList: [],
      uploading: false,
      totalProgress: 0,
      msg: '',
      MAX_CONCURRENT: 3 // ✅ 新增:最大并发数,控制同时上传的分片数量
    }
  },
  methods: {
    // 1. 选择文件
    async handleFileChange(e) {
      const file = e.target.files[0]
      if (!file) return
      this.file = file
      this.msg = '正在计算文件指纹...'
      this.fileHash = await this.getFileHash(file)
      this.msg = `文件:${file.name},hash:${this.fileHash.slice(0, 10)}...`
    },

    // 2. 计算文件 MD5(优化:大文件完整计算,避免 hash 冲突)
    getFileHash(file) {
      return new Promise((resolve, reject) => {
        const spark = new SparkMD5.ArrayBuffer()
        const fileReader = new FileReader()
        const chunkSize = 2 * 1024 * 1024
        let offset = 0

        const loadNext = () => {
          const slice = file.slice(offset, offset + chunkSize)
          fileReader.readAsArrayBuffer(slice)
        }

        fileReader.onload = (e) => {
          spark.append(e.target.result)
          offset += e.target.result.byteLength
          if (offset < file.size) {
            loadNext()
          } else {
            resolve(spark.end())
          }
        }

        fileReader.onerror = reject
        loadNext()
      })
    },

    // 3. 开始上传(主流程,修复并发+进度)
    async handleUpload() {
      if (!this.file) return alert('请选择文件')
      this.uploading = true
      this.totalProgress = 0
      this.msg = ''

      try {
        // 1)查询已上传分片
        const { data } = await axios.post('http://localhost:3000/checkfile', {
          fileHash: this.fileHash,
          fileName: this.file.name
        })

        // 秒传
        if (!data.shouldUpload) {
          this.msg = '✅ 秒传成功:文件已存在'
          this.uploading = false
          this.totalProgress = 100
          return
        }
        this.uploadedChunkList = data.uploadedChunks.map(String) // 统一转字符串,避免类型不匹配

        // 2)切分文件
        this.chunkList = this.createChunks(this.file)
        const total = this.chunkList.length
        let uploadedCount = this.uploadedChunkList.length // 已上传分片数
        this.totalProgress = (uploadedCount / total) * 100 // 初始化进度

        // 3)过滤出需要上传的分片
        const needUploadChunks = this.chunkList
          .map((chunk, index) => ({ chunk, index }))
          .filter(item => !this.uploadedChunkList.includes(item.index.toString()))

        // ✅ 4)并发控制上传(核心优化,避免后端 500)
        await this.concurrentUpload(needUploadChunks, total, (count) => {
          uploadedCount += count
          this.totalProgress = (uploadedCount / total) * 100
        })

        // 5)通知后端合并文件
        const mergeRes = await axios.post('http://localhost:3000/mergefile', {
          fileHash: this.fileHash,
          fileName: this.file.name,
          chunkCount: this.chunkList.length
        })

        if (mergeRes.data.code === 0) {
          this.msg = '✅ 上传 + 合并完成!'
          this.totalProgress = 100
        } else {
          this.msg = `❌ 合并失败:${mergeRes.data.msg || '未知错误'}`
        }
      } catch (error) {
        console.error('上传错误:', error)
        this.msg = `❌ 上传失败:${error.message || '未知错误'}`
      } finally {
        this.uploading = false
      }
    },

    // ✅ 并发控制上传方法
    async concurrentUpload(chunks, total, onProgress) {
      console.log('999999 分片数量', chunks)
      const results = []
      // 分批上传,每批 MAX_CONCURRENT 个
      for (let i = 0; i < chunks.length; i += this.MAX_CONCURRENT) {
        const batch = chunks.slice(i, i + this.MAX_CONCURRENT)
        const batchPromises = batch.map(async({ chunk, index }) => {
          const formData = new FormData()
          formData.append('chunk', chunk)
          formData.append('fileHash', this.fileHash)
          formData.append('chunkIndex', index)

          // 重试逻辑:失败自动重试 2 次
          for (let retry = 0; retry < 2; retry++) {
            try {
              await axios.post('http://localhost:3000/uploadchunk', formData, {
                headers: { 'Content-Type': 'multipart/form-data' }
              })
              return { success: true, index }
            } catch (e) {
              console.warn(`分片 ${index} 上传失败,重试 ${retry + 1}`)
              if (retry === 1) throw e
              await new Promise(resolve => setTimeout(resolve, 1000)) // 重试间隔 1s
            }
          }
        })

        const batchResults = await Promise.allSettled(batchPromises)
        results.push(...batchResults)
        // 更新进度:每批完成后计算
        const successCount = batchResults.filter(r => r.status === 'fulfilled' && r.value.success).length
        onProgress(successCount)
      }

      // 检查是否有失败的分片
      const failed = results.filter(r => r.status === 'rejected' || !r.value?.success)
      if (failed.length > 0) {
        throw new Error(`有 ${failed.length} 个分片上传失败`)
      }
    },

    // 切分文件
    createChunks(file) {
      const chunks = []
      let start = 0
      while (start < file.size) {
        const end = Math.min(start + this.CHUNK_SIZE, file.size)
        chunks.push(file.slice(start, end))
        start = end
      }
      return chunks
    }
  }
}
</script>

2、安装依赖

npm install axios spark-md5 --save

3、启动服务

npm run serve

总结:

  1. 查询列表(文件是否已上传/已传切片index)-文件hash唯一性(spark-md5获取)
  2. 开始上传,过滤已上传的切片数
  3. 剩余切片分批次并行上传(第一次失败,自动重试 1 次,第二次失败,抛错)
  4. 全部切片上传成功后,调用合并接口(通知后端可以合并切片数了)

秒传:文件完整存在 → 直接跳过上传

断点续传:只传缺失分片 → 断网 / 刷新可恢复(hash查询,再过滤)

并发控制:分批次,防止同时发大量请求导致崩溃

备注:按以上步骤,直接可以实践操作

❌