改了个字段,用户集体白屏——前端 LocalStorage 数据迁移血泪史

JavaScript性能优化实战 10w+人浏览 469人参与

在这里插入图片描述

阅读大约需 10 分钟


一次线上事故

周五下午,小禾信心满满地发布了新版本。

这次更新加了两个新功能:

  1. 故事类型(contentType):区分"故事"和"脚本"
  2. 场景分组(scenes):把分镜按场景整理

改动不大,测试通过,上线!

然后,客服群炸了。

用户A:更新后打开就白屏!
用户B:我的故事都没了!!!
用户C:点进去报错 Cannot read property 'toLowerCase' of undefined

小禾一脸懵:测试的时候好好的啊?

他打开控制台,看到了一行熟悉的报错:

Uncaught TypeError: Cannot read property 'forEach' of undefined
    at renderScenes (App.vue:42)

定位到代码:

// 新版本的代码
story.scenes.forEach(scene => {
  // 渲染场景...
})

等等,老用户的数据里根本没有 scenes 字段啊!

小禾终于明白了:老用户的数据结构是旧的,新代码直接访问新字段,炸了。

v1.0 老用户的数据:
{
  "stories": [{ "title": "故事1", "content": "..." }]
}

v2.0 新版本期望的数据:
{
  "stories": [{
    "title": "故事1",
    "content": "...",
    "contentType": "story",  // 新增!老数据没有
    "scenes": []              // 新增!老数据没有
  }]
}

小禾紧急回滚了代码,开始思考:怎么才能让老数据兼容新代码?


数据迁移的基本思路

冷静下来后,小禾画了张图:
在这里插入图片描述

核心思路很简单:在数据进入应用之前,先检查并补全缺失的字段。

小禾定了几条原则:

原则 1:永远不要假设数据结构是完整的
原则 2:新字段必须有默认值
原则 3:迁移逻辑要幂等(执行多次结果相同)
原则 4:只做增量修改,不删除原有数据


第一版:简单粗暴的字段补全

小禾写了个迁移函数:

// stores/story.ts
export const useStoryStore = defineStore('story', {
  // ...

  actions: {
    loadState() {
      try {
        const storiesJson = localStorage.getItem('stories')
        if (storiesJson) {
          let stories = JSON.parse(storiesJson)

          // 关键:加载后立即迁移
          stories = this.migrateData(stories)

          this.stories = stories
        }
      } catch (e) {
        console.error('加载失败:', e)
      }
    },

    /**
     * 数据迁移主函数
     */
    migrateData(stories: any[]): Story[] {
      return stories.map(story => this.migrateStory(story))
    },

    /**
     * 单个故事的迁移
     */
    migrateStory(story: any): Story {
      // v1 → v2: 添加 contentType
      if (!story.contentType) {
        story.contentType = 'story'
        console.log(`[迁移] 为故事 "${story.title}" 添加 contentType`)
      }

      // v1 → v2: 添加 scenes 数组
      if (!story.scenes) {
        story.scenes = []
        console.log(`[迁移] 为故事 "${story.title}" 添加 scenes`)
      }

      return story as Story
    }
  }
})

重新发布,老用户的数据正常了!

但小禾觉得这样写不够优雅。如果以后再有 v3、v4 呢?迁移逻辑会越来越乱。


第二版:版本号驱动的迁移

小禾决定引入数据版本号,让迁移变得有序:

const CURRENT_DATA_VERSION = 2

interface StorageData {
  version: number
  stories: Story[]
}

actions: {
  loadState() {
    const raw = localStorage.getItem('appData')
    if (!raw) return

    let data: StorageData

    try {
      const parsed = JSON.parse(raw)

      // 兼容没有 version 的老数据(v1)
      if (!parsed.version) {
        data = { version: 1, stories: parsed.stories || parsed }
      } else {
        data = parsed
      }
    } catch (e) {
      console.error('数据解析失败:', e)
      return
    }

    // 按版本逐步迁移
    if (data.version < 2) {
      data = this.migrateV1toV2(data)
    }
    // 未来可以继续添加
    // if (data.version < 3) {
    //   data = this.migrateV2toV3(data)
    // }

    // 更新版本号并保存
    data.version = CURRENT_DATA_VERSION
    this.stories = data.stories
    this.saveState()  // 保存迁移后的数据
  },

  migrateV1toV2(data: any): StorageData {
    console.log('[迁移] 执行 v1 → v2')

    const stories = data.stories.map((story: any) => ({
      ...story,
      contentType: story.contentType || 'story',
      scenes: story.scenes || []
    }))

    return { version: 2, stories }
  }
}

这样每次升级都有迹可循,迁移逻辑也更清晰。


真实案例:多层嵌套的迁移

几周后,小禾又加了新功能。这次数据结构变化更大:

// v2 的结构
interface Shot_V2 {
  id: string
  prompt: string
  imageUrl?: string
}

// v3 新增了状态字段和动画配置
interface Shot_V3 {
  id: string
  prompt: string
  imageUrl?: string
  status: 'idle' | 'generating' | 'generated' | 'error'  // 新增
  animation: {                                             // 新增
    videoUrl?: string
    duration: number
  }
}

迁移函数需要处理嵌套结构:

const CURRENT_DATA_VERSION = 3

migrateV2toV3(data: StorageData): StorageData {
  console.log('[迁移] 执行 v2 → v3')

  const stories = data.stories.map(story => ({
    ...story,
    // 新增:角色列表
    characters: story.characters || [],
    // 迁移场景
    scenes: (story.scenes || []).map(scene => this.migrateScene_V2toV3(scene))
  }))

  return { version: 3, stories }
},

migrateScene_V2toV3(scene: any): Scene {
  return {
    ...scene,
    // 新增:全局种子
    useGlobalSeed: scene.useGlobalSeed ?? false,
    globalSeed: scene.globalSeed ?? Math.floor(Math.random() * 1000000),
    // 迁移分镜
    shots: (scene.shots || []).map(shot => this.migrateShot_V2toV3(shot))
  }
},

migrateShot_V2toV3(shot: any): Shot {
  return {
    ...shot,
    // 新增:状态(根据是否有图片推断)
    status: shot.status || (shot.imageUrl ? 'generated' : 'idle'),
    // 新增:动画配置
    animation: shot.animation || {
      videoUrl: undefined,
      duration: 3
    }
  }
}

现在加载数据的流程变成了:

loadState() {
  const raw = localStorage.getItem('appData')
  if (!raw) return

  let data = this.parseStorageData(raw)
  if (!data) return

  // 链式迁移:v1 → v2 → v3
  if (data.version < 2) {
    data = this.migrateV1toV2(data)
  }
  if (data.version < 3) {
    data = this.migrateV2toV3(data)
  }

  data.version = CURRENT_DATA_VERSION
  this.stories = data.stories
  this.saveState()
}

完美的链式迁移,v1 用户也能无缝升级到 v3。


防御性编程:运行时容错

迁移函数能处理大部分情况,但小禾还是不放心。

毕竟,还有些边界情况:

  • 用户手动改了 localStorage
  • 某个 bug 导致字段被删了
  • 从外部导入了不完整的数据

他决定在代码层面也加上防御:

// ❌ 危险:假设数据完整
function getSceneTitle(story: Story, sceneId: string) {
  return story.scenes.find(s => s.id === sceneId).title
}

// ✅ 安全:可选链 + 默认值
function getSceneTitle(story: Story, sceneId: string) {
  return story.scenes?.find(s => s.id === sceneId)?.title ?? '未命名场景'
}

TypeScript 可以帮上忙:

// 明确哪些字段可能不存在
interface Shot {
  id: string
  prompt: string
  imageUrl?: string       // 可选
  status?: GenerationStatus  // 可选
}

// 使用时 TypeScript 会提醒
shot.status.toLowerCase()  // ❌ 编译错误:Object is possibly 'undefined'
shot.status?.toLowerCase() // ✅ 必须用可选链

小禾在整个代码库搜索了一遍,把所有"假设数据完整"的代码都改成了防御式写法。


测试迁移逻辑

迁移函数这么重要,当然要写测试:

describe('数据迁移', () => {
  let store: ReturnType<typeof useStoryStore>

  beforeEach(() => {
    setActivePinia(createPinia())
    store = useStoryStore()
  })

  it('v1 数据应该正确迁移到 v3', () => {
    const v1Data = {
      stories: [{
        id: '1',
        title: '测试故事',
        content: '内容'
        // 没有 contentType, scenes, characters
      }]
    }

    const result = store.migrateData(v1Data.stories)

    // 验证新字段
    expect(result[0].contentType).toBe('story')
    expect(result[0].scenes).toEqual([])
    expect(result[0].characters).toEqual([])
  })

  it('v2 数据应该正确迁移到 v3', () => {
    const v2Data = {
      version: 2,
      stories: [{
        id: '1',
        title: '测试故事',
        contentType: 'story',
        scenes: [{
          id: 's1',
          title: '场景1',
          shots: [{
            id: 'shot1',
            prompt: '测试',
            imageUrl: 'http://example.com/1.jpg'
            // 没有 status, animation
          }]
        }]
      }]
    }

    const result = store.migrateV2toV3(v2Data)

    // 验证分镜的新字段
    const shot = result.stories[0].scenes[0].shots[0]
    expect(shot.status).toBe('generated')  // 有图片,所以是 generated
    expect(shot.animation).toEqual({ videoUrl: undefined, duration: 3 })
  })

  it('迁移应该是幂等的', () => {
    const data = [{
      id: '1',
      title: '测试',
      contentType: 'story',
      scenes: []
    }]

    const result1 = store.migrateData(data)
    const result2 = store.migrateData(result1)

    // 多次迁移结果应该相同
    expect(result1).toEqual(result2)
  })

  it('应该处理损坏的数据', () => {
    const corruptedData = [{
      id: '1'
      // 缺少 title 和其他字段
    }]

    // 不应该抛出异常
    expect(() => store.migrateData(corruptedData)).not.toThrow()
  })
})

手动测试清单:

- [x] 全新用户(无 localStorage)能正常使用
- [x] v1 用户升级后数据不丢失
- [x] v2 用户升级后数据不丢失
- [x] 清空 localStorage 后能正常使用
- [x] 损坏的 JSON 能优雅处理(不白屏)

迁移的最佳实践

经过这次事故,小禾总结了一套完整的迁移方案:

1. 数据结构变更时

变更类型处理方式
新增字段迁移函数中添加默认值
字段改名迁移函数中做映射
删除字段可以忽略,不影响运行
类型变更迁移函数中做转换

2. 发版流程

1. 修改类型定义(interfaces)
2. 更新 CURRENT_DATA_VERSION
3. 编写对应的迁移函数
4. 添加单元测试
5. 本地用老数据测试
6. 发布上线

3. 代码模板

const CURRENT_DATA_VERSION = 3

interface StorageData {
  version: number
  stories: Story[]
}

export const useStoryStore = defineStore('story', {
  state: () => ({
    stories: [] as Story[],
    // ...
  }),

  actions: {
    loadState() {
      const raw = localStorage.getItem('appData')
      if (!raw) return

      try {
        let data = this.parseAndValidate(raw)

        // 链式迁移
        while (data.version < CURRENT_DATA_VERSION) {
          data = this.migrate(data)
        }

        this.stories = data.stories
        this.saveState()  // 保存迁移后的数据
      } catch (e) {
        console.error('数据加载失败:', e)
        // 可以选择重置为空数据
      }
    },

    parseAndValidate(raw: string): StorageData {
      const parsed = JSON.parse(raw)

      // 兼容老版本(无 version 字段)
      if (!parsed.version) {
        return { version: 1, stories: parsed.stories || [] }
      }

      return parsed
    },

    migrate(data: StorageData): StorageData {
      const migrations: Record<number, (d: StorageData) => StorageData> = {
        1: this.migrateV1toV2,
        2: this.migrateV2toV3,
        // 未来的迁移...
      }

      const migrateFn = migrations[data.version]
      if (migrateFn) {
        return migrateFn.call(this, data)
      }

      return data
    },

    migrateV1toV2(data: StorageData): StorageData {
      console.log('[迁移] v1 → v2')
      // 迁移逻辑...
      return { version: 2, stories: /* ... */ }
    },

    migrateV2toV3(data: StorageData): StorageData {
      console.log('[迁移] v2 → v3')
      // 迁移逻辑...
      return { version: 3, stories: /* ... */ }
    }
  }
})

小禾的感悟

这次线上事故,
让我明白一个道理:

代码在变,
数据不变,
老用户的 localStorage,
还停在过去。

每次改结构,
都要问自己:
老数据怎么办?

版本号是灯塔,
迁移函数是桥梁,
把老用户安全地,
渡到新版本。

记住四条原则:
1. 别假设数据完整
2. 新字段要有默认值
3. 迁移要能重复执行
4. 只增不删,保护数据

测试不能少,
防御要到位,
这样才能睡得安稳。

用户的数据是宝贝,
弄丢了,
就再也找不回来了。

小禾把迁移方案文档化,分享给团队。

从此,每次数据结构变更,大家都会先问:迁移函数写了吗?


下一个合集,我们聊聊后端架构的那些事儿。

敬请期待。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员义拉冠

你的鼓励是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值