广告位 · 页头(后台-模块管理-新建模块后替换此区域)

如何在 Vue 3 项目中工程化接入 Sia 去中心化存储?

Sia 不是简单的文件服务器,它把数据切成扇区、加密后分发给多个主机,并通过智能合约维护存储租约。在 Vue 3 项目里接入 Sia,如果只是把上传框接到一个 HTTP 接口,很快会碰到节点认证、跨域、租约不足、大文件进度丢失等问题。本文把 Sia 接入拆成四个可以落地的工程层:概念建模、响应式封装、大文件策略、安全边界。

如何在 Vue 3 项目中工程化接入 Sia 去中心化存储?

一、先理解 Sia 的存储模型与 API 边界

Sia 的核心机制是将文件切成小扇区,再使用 Reed-Solomon 纠错码生成冗余分片,这些分片会被分发给网络中的多个主机。区块链负责记录这些存储合约,租约费用使用 SC 币支付。这跟传统对象存储最重要的区别在于:数据可用性不依赖某一个机房,而是依赖合约、主机在线率和冗余度。因此前端工程不能只关注上传是否成功,还要关注合约健康度和租约资金是否充足。

在工程化接入时,一般通过 Sia 官方的 renterd 节点提供 REST API。该 API 使用 Basic Auth 认证,需要在请求头中携带节点密码。开发阶段可以把 renterd 地址指向本机节点,生产环境则必须经过后端代理。配置层可以先用一个独立的 TypeScript 文件统一管理这些参数,避免在组件中散落硬编码。

// sia.config.ts
export const siaConfig = {
  renterdUrl: import.meta.env.VITE_SIA_RENTERD_URL ?? 'http://127.0.0.1:9980',
  apiPassword: import.meta.env.VITE_SIA_API_PASSWORD ?? '',
  defaultBucket: 'vue-app-bucket',
  maxConcurrentUploads: 3
}

renterd 里的 bucket 可以理解为命名空间,适合按项目或者业务模块划分。比如用户头像可以放在 user-avatar 桶,合同附件放在 contract-file 桶。桶的创建频率很低,通常由运维脚本或首次启动流程完成,Vue 端更多是针对对象进行上传、下载、删除和健康度查询。只有把这些概念先厘清,后续 composable 才不会把租约管理、桶创建和对象上传混在一起。

二、封装一个可复用的 Sia 存储 composable

Vue 3 的组合式 API 很适合把上传过程收敛成独立函数。一个负责上传的 composable 不绑定任何按钮或弹窗,只向外暴露响应式状态和执行方法。组件拿到 uploading、progress、error 后,可以自由渲染进度条、禁用按钮或展示错误提示。下面是一个基础但完整的示例。

import { ref } from 'vue'
import type { Ref } from 'vue'
interface UploadOptions {
  file: File
  bucket?: string
  onProgress?: (percent: number) => void
}
export function useSiaUpload() {
  const uploading: Ref<boolean> = ref(false)
  const progress: Ref<number> = ref(0)
  const error: Ref<string | null> = ref(null)
  async function upload({ file, bucket = 'default', onProgress }: UploadOptions) {
    uploading.value = true
    error.value = null
    progress.value = 0
    try {
      const baseUrl = import.meta.env.VITE_SIA_RENTERD_URL ?? 'http://127.0.0.1:9980'
      const password = import.meta.env.VITE_SIA_API_PASSWORD ?? ''
      const headers = new Headers()
      if (password) {
        headers.set('Authorization', 'Basic ' + btoa(':' + password))
      }
      const target = `${baseUrl}/api/bus/buckets/${bucket}/objects/${encodeURIComponent(file.name)}`
      const response = await fetch(target, {
        method: 'PUT',
        headers,
        body: file
      })
      if (!response.ok) {
        throw new Error(`上传失败:${response.status}`)
      }
      progress.value = 100
      onProgress?.(100)
    } catch (err) {
      error.value = err instanceof Error ? err.message : '未知错误'
    } finally {
      uploading.value = false
    }
  }
  return { uploading, progress, error, upload }
}

这里用 fetch 直接发送 File 对象,浏览器会采用流式方式读取磁盘文件,不会把整个文件一次性加载进 JavaScript 内存,这一点对中等大小的文件非常友好。如果后续需要取消上传,可以在 composable 内部维护一个 AbortController 实例,把 signal 传给请求,组件里调用 cancel 即可中断。

真实项目通常不止一个文件,建议在这个 composable 之上再接一层 Pinia store。store 中维护任务队列、并发数、各文件进度和可恢复状态。这样即使组件被卸载,上传任务依然可以在后台继续,用户切换页面不会导致文件传输中断。

三、大文件分片、断点续传与租约状态回退

虽然直接发送 File 对象能够覆盖大部分常规上传场景,但几百 MB 或几个 GB 的文件需要更细粒度的控制。一次性连接一旦中断,整个文件都要重传,代价很高。更稳妥的方案是把文件按固定大小切片,每个分片单独上传,服务端或客户端记录已完成的片索引,重新上传时只补缺失分片。下面是分片策略的基础实现。

async function uploadInChunks(file: File, bucket: string, baseUrl: string, password: string) {
  const chunkSize = 5 * 1024 * 1024
  const totalChunks = Math.ceil(file.size / chunkSize)
  for (let index = 0; index < totalChunks; index += 1) {
    const start = index * chunkSize
    const end = Math.min(start + chunkSize, file.size)
    const chunk = file.slice(start, end)
    const form = new FormData()
    form.append('chunk', chunk, `${file.name}.part${index}`)
    form.append('index', String(index))
    form.append('total', String(totalChunks))
    const headers = new Headers()
    if (password) {
      headers.set('Authorization', 'Basic ' + btoa(':' + password))
    }
    const response = await fetch(
      `${baseUrl}/api/bus/buckets/${bucket}/objects/${encodeURIComponent(file.name)}/multipart`,
      { method: 'POST', headers, body: form }
    )
    if (!response.ok) {
      throw new Error(`分片 ${index} 上传失败`)
    }
  }
}

这个示例展示的是分片思路,具体接口路径要以当前 renterd 版本为准。工程化的重点不在某一条 URL,而在把大文件拆小、降低失败重传成本、保存检查点。这样即使某个主机暂时离线,也可以通过后续重试恢复,而不是让用户重新选择文件。分片进度可以结合 onProgress 实时回传,组件侧用百分比展示比单纯转圈更符合用户预期。

另一个容易被忽视的问题是租约状态。Sia 文件上传成功后,如果合约资金不足,数据可能不会被持续保存。工程化接入时应当提供健康度查询,在文件列表里展示主机的在线比例和剩余租约时间。节点 API 可以返回合约和钱包信息,前端可以根据这些数据给出警告,例如“该文件当前仅有 60% 的冗余分片可用”。这种状态不适合在每次上传时才检查,应当做成定时轮询或用户进入文件管理页时统一刷新。

四、安全边界与构建代理:不要把节点密码暴露到浏览器

很多开发者为了方便,会把 renterd 地址和密码直接写进 Vite 环境变量。实际上 VITE_ 前缀的变量会被打包进客户端代码,任何用户都能在浏览器源码里看到。因此生产环境绝不能让 Vue 前端直连 renterd 节点,应交由 BFF 层或者反向代理统一处理认证。开发环境可以用 Vite 的代理功能临时解决跨域。

import { defineConfig } from 'vite'
export default defineConfig({
  server: {
    proxy: {
      '/sia-api': {
        target: 'http://127.0.0.1:9980',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/sia-api/, '')
      }
    }
  }
})

生产环境中,前端只需要请求自己的后端接口,由后端注入 Basic Auth 头并转发到 renterd。这样做还能进一步做权限隔离,例如普通用户只允许向指定 bucket 写入,管理员才能删除对象或查询合约明细。错误处理也应该分层:网络断开、认证失败、租约不足、空间不足应当对应不同的错误码,前端展示不同文案,而不是统一抛出一个“上传失败”。

Vue 3 加 Sia 的工程化核心不在 UI 层,而在把去中心化存储的非确定性和合约状态翻译成前端可消费的响应式状态。封装好基础 composable、分片策略、健康度查询和安全代理之后,Sia 可以像普通对象存储一样嵌入组件树,但底层数据却不再依赖某一个中心节点。

广告位 · 文章底部(后台-模块管理替换此区域)