uni-app x 实现本地 JSON 文件的导入导出(APP端可用)

感觉好久没写东西了,最近一段时间也是事儿比较多。

而且我感觉自从AI开发以后,好像很多东西我也不用了解的那么透彻了。

直接交给AI齐活了,唉!技术人的喜讯还是悲哀?

不知道大家用没用过 uni-app 开发,我之前用 uni-app 开发的 Android App 一直感觉太慢了,比较卡,最近发现uni-app的升级版本 uni-app x 迭代的比较快,所以搞一下。

正好最近有个App的小项目,直接用uni-app x实现的,感觉和原生相比,虽然还有一定的差距,但是已经不太明显了。

一、功能需求与技术亮点

1. 核心需求

实现APP端本地JSON文件的两大核心能力:

  • JSON导出:读取本地Storage缓存数据,自动生成规范命名的JSON文件,保存至APP沙盒自定义目录

  • JSON导入:选择本地JSON文件,校验文件格式、解析数据,支持「追加导入/覆盖导入」两种模式,兼容单对象、数组格式JSON

2. 技术亮点

  • 纯前端实现,零后端接口依赖,离线可用

  • 基于uni-app X原生文件系统API,适配Android、IOS双端

  • 完善的数据格式化、异常捕获、格式校验机制,避免脏数据

  • 支持导入模式选择,兼顾数据保留与全覆盖场景

  • 自动记录导入/导出时间、数据条数,可视化数据状态

二、实现原理

uni-app X 操作本地文件核心依赖 uni.getFileSystemManager() 文件管理器API,配合本地Storage实现数据流转:

  1. 导出流程:读取Storage缓存数据 → 格式化JSON字符串 → 创建本地目录 → 写入JSON文件 → 记录导出时间

  2. 导入流程:选择本地文件 → 校验JSON格式 → 读取文件内容 → 解析标准化数据 → 按模式导入Storage → 记录导入时间

三、代码实现

html 复制代码
<template>
	<!-- #ifdef APP -->
	<scroll-view style="flex:1" scroll-y>
	<!-- #endif -->
		<view class="setting-container">
			<view class="function-grid">
				<view class="function-item" @click="onImport">
					<text class="function-title">导入数据</text>
					<text class="function-desc">从本地JSON文件导入</text>
				</view>

				<view class="function-item" @click="onExport">
					<text class="function-title">导出数据</text>
					<text class="function-desc">备份为本地JSON文件</text>
				</view>
			</view>

			<view class="info-section">
				<view class="info-item">
					<text class="info-label">当前数据条数</text>
					<text class="info-value">{{ dataCount }}</text>
				</view>
				<view class="info-item">
					<text class="info-label">最后导出时间</text>
					<text class="info-value">{{ lastExportTime }}</text>
				</view>
				<view class="info-item">
					<text class="info-label">最后导入时间</text>
					<text class="info-value">{{ lastImportTime }}</text>
				</view>
			</view>
		</view>
	<!-- #ifdef APP -->
	</scroll-view>
	<!-- #endif -->
</template>
typescript 复制代码
// 定义本地数据结构体
type Contact = {
    id : string
    user : string
    password : string
    username : string
    other : string
}

// 本地缓存key常量
const importMetaKey = 'setting__TYPE_lastImportTime'
const exportMetaKey = 'setting__TYPE_lastExportTime'

// 响应式数据
const dataCount = ref<number>(0)
const lastExportTime = ref<string>('从未导出')
const lastImportTime = ref<string>('从未导入')

// 初始化文件管理器
const fs = uni.getFileSystemManager()

// 过滤有效业务数据缓存key(过滤系统配置缓存)
function getContactStorageKeys() : string[] {
    const storageInfo = uni.getStorageInfoSync()
    const keys = storageInfo.keys
    const result : string[] = []
    for (let i = 0; i < keys.length; i++) {
        const key = keys[i]
        if (key.indexOf('__TYPE') < 0) {
            result.push(key)
        }
    }
    return result
}

// 格式化当前时间
function formatNow() : string {
    const now = new Date()
    let year = now.getFullYear().toString()
    let month = (now.getMonth() + 1).toString().padStart(2, '0')
    let day = now.getDate().toString().padStart(2, '0')
    let hours = now.getHours().toString().padStart(2, '0')
    let minutes = now.getMinutes().toString().padStart(2, '0')
    return `${year}-${month}-${day} ${hours}:${minutes}`
}

// 生成自定义导出文件名(时间戳唯一命名)
function buildExportFileName() : string {
    const now = new Date()
    const year = now.getFullYear()
    const month = (now.getMonth() + 1).toString().padStart(2, '0')
    const day = now.getDate().toString().padStart(2, '0')
    const hours = now.getHours().toString().padStart(2, '0')
    const minutes = now.getMinutes().toString().padStart(2, '0')
    const seconds = now.getSeconds().toString().padStart(2, '0')
    return `data_backup_${year}${month}${day}_${hours}${minutes}${seconds}.json`
}

// 确保导出目录存在(不存在则创建)
function ensureExportDir(dirPath : string) : void {
    try {
        fs.mkdir({ dirPath, recursive: true })
    } catch (e) {
        console.log('目录已存在,无需创建')
    }
}

// 校验文件是否为JSON格式
function isJsonFileName(fileName : string) : boolean {
    return fileName.toLowerCase().endsWith('.json')
}

// 安全获取JSON字段(空值兜底)
function getJsonStringField(data : UTSJSONObject, key : string) : string {
    const value = data[key] as string | null
    return value ?? ''
}

// 标准化单条导入数据
function normalizeImportContact(data : UTSJSONObject) : Contact | null {
    return {
        id: generateUniqueId(),
        user: getJsonStringField(data, 'user'),
        password: getJsonStringField(data, 'password'),
        username: getJsonStringField(data, 'username'),
        other: getJsonStringField(data, 'other')
    }
}

// 解析JSON文本为标准数据数组(兼容单对象/数组格式)
function parseImportContacts(jsonText : string) : Contact[] {
    const parsed = JSON.parse(jsonText) as UTSJSONObject | UTSJSONObject[] | null
    const result : Contact[] = []
    if (!parsed) return result

    // 数组格式遍历解析
    if (parsed instanceof Array) {
        parsed.forEach(item => {
            const normalized = normalizeImportContact(item)
            if (normalized) result.push(normalized)
        })
    } else {
        // 单个对象直接解析
        const single = normalizeImportContact(parsed)
        if (single) result.push(single)
    }
    return result
}

// 获取本地所有缓存业务数据
function getStoredContactList() : Contact[] {
    const keys = getContactStorageKeys()
    const result : Contact[] = []
    keys.forEach(key => {
        const rawValue = uni.getStorageSync(key) as string
        if (!rawValue) return
        try {
            const list = parseImportContacts(rawValue)
            result.push(...list)
        } catch (e) {
            console.log('跳过无效数据:', key)
        }
    })
    return result
}

// 保存导入数据到本地缓存(支持覆盖/追加模式)
function saveImportedContacts(list : Contact[], replaceMode : boolean) : number {
    // 覆盖模式:清空原有数据
    if (replaceMode) {
        const oldKeys = getContactStorageKeys()
        oldKeys.forEach(key => uni.removeStorageSync(key))
    }

    let savedCount = 0
    list.forEach(contact => {
        contact.id = contact.id || generateUniqueId()
        uni.setStorageSync(contact.id, JSON.stringify(contact))
        savedCount++
    })
    return savedCount
}

// 导入数据二次确认弹窗
function confirmImportContacts(list : Contact[], fileName : string) : void {
    if (list.length === 0) {
        uni.showToast({ title: '未解析到有效数据', icon: 'none' })
        return
    }

    // 选择导入模式
    uni.showActionSheet({
        itemList: ['追加导入(保留现有数据)', '覆盖导入(清空后导入)'],
        success: (sheetRes) => {
            const replaceMode = sheetRes.tapIndex === 1
            const modeText = replaceMode ? '覆盖' : '追加'
            uni.showModal({
                title: '确认导入',
                content: `文件:${fileName}\n共解析 ${list.length} 条数据,${modeText}模式导入?`,
                confirmText: '开始导入',
                success: (modalRes) => {
                    if (modalRes.confirm) {
                        const savedCount = saveImportedContacts(list, replaceMode)
                        const nowText = formatNow()
                        lastImportTime.value = nowText
                        uni.setStorageSync(importMetaKey, nowText)
                        uni.showToast({ title: `成功导入 ${savedCount} 条数据`, icon: 'success' })
                    }
                }
            })
        }
    })
}

// 读取选中的本地JSON文件
function readSelectedJsonFile(filePath : string, fileName : string) : void {
    fs.readFile({
        filePath,
        encoding: 'utf-8',
        success: (res) => {
            try {
                const contacts = parseImportContacts(res.data as string)
                confirmImportContacts(contacts, fileName)
            } catch (e) {
                uni.showToast({ title: 'JSON解析失败,格式错误', icon: 'none' })
            }
        },
        fail: () => uni.showToast({ title: '文件读取失败', icon: 'none' })
    })
}

// 导入入口方法
function onImport() : void {
    uni.chooseFile({
        type: 'all',
        count: 1,
        success: (res) => {
            const file = res.tempFiles[0]
            if (!isJsonFileName(file.name)) {
                uni.showToast({ title: '请选择JSON格式文件', icon: 'none' })
                return
            }
            readSelectedJsonFile(file.path, file.name)
        },
        fail: () => uni.showToast({ title: '文件选择失败', icon: 'none' })
    })
}

// 导出入口方法
function onExport() : void {
    const contacts = getStoredContactList()
    if (contacts.length === 0) {
        uni.showToast({ title: '暂无可导出数据', icon: 'none' })
        return
    }

    // 拼接导出路径、生成文件
    const jsonText = JSON.stringify(contacts)
    const exportDir = uni.env.SANDBOX_PATH + '/data-backup'
    ensureExportDir(exportDir)
    const fileName = buildExportFileName()
    const filePath = `${exportDir}/${fileName}`

    // 写入本地文件
    fs.writeFile({
        filePath,
        data: jsonText,
        encoding: 'utf-8',
        success: () => {
            const nowText = formatNow()
            lastExportTime.value = nowText
            uni.setStorageSync(exportMetaKey, nowText)
            uni.showModal({
                title: '导出成功',
                content: `已导出 ${contacts.length} 条数据\n文件路径:${filePath}`,
                showCancel: false
            })
        },
        fail: () => uni.showToast({ title: '文件导出失败', icon: 'none' })
    })
}

总结

这部分代码我也让AI帮忙给完善了一下,感觉健壮性还是比较好的。

有其他需要的兄弟也可以私信我~

相关推荐
灯澜忆梦1 小时前
GO---map函数
开发语言·前端·后端·golang
喜欢打篮球的普通人2 小时前
LLVM后端指令选择
android·java·javascript
踩蚂蚁2 小时前
在 ESP32 上跑自定义唤醒词:从 TFLite 到离线语音唤醒,一个下午搞定
前端
hunterandroid2 小时前
Room 数据库迁移:让本地数据升级更稳
android·前端
阿巴斯甜2 小时前
Compose NavHost的使用:
android
徐小夕2 小时前
倒腾2天,我开源了一款AI驱动的实时 3D CAD / 参数化建模工作台
前端·架构·github
雁鸣零落2 小时前
浏览器扩展 CaptionGo,在网页视频上显示双语字幕,支持 PC 和手机端使用
android·chrome·edge·firefox·安卓
少年白马醉春风丶2 小时前
任务分解的经典范式 · 和 ReAct「走一步看一步」到底差在哪
前端
是烨笙啊2 小时前
从 npm 与 Bun 的选择看 AI 时代的“最佳实践”焦虑
前端·人工智能·npm