
阅读大约需 10 分钟
一次线上事故
周五下午,小禾信心满满地发布了新版本。
这次更新加了两个新功能:
- 故事类型(
contentType):区分"故事"和"脚本" - 场景分组(
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. 只增不删,保护数据
测试不能少,
防御要到位,
这样才能睡得安稳。
用户的数据是宝贝,
弄丢了,
就再也找不回来了。
小禾把迁移方案文档化,分享给团队。
从此,每次数据结构变更,大家都会先问:迁移函数写了吗?
下一个合集,我们聊聊后端架构的那些事儿。
敬请期待。


被折叠的 条评论
为什么被折叠?



