浅谈 Vue SPA 网站 URL 保存数据实践

本文最后更新于:2020年12月25日 上午

场景

该功能吾辈已经封装成 NPM 库 vue-url-persist

在使用 Vue SPA 开发面向普通用户的网站时,吾辈也遇到了一些之前未被重视,但却实实在在存在的问题,这次便浅谈一下 SPA 网站将所有数据都存储到内存中导致数据很容易丢失以及吾辈思考并尝试的解决方案。

参考:SPA 全称 single page application,意为 单页应用,不是泥萌想的那样!#笑哭

思维导图

首先列出为什么遇到这个问题,具体场景及解决的问题是什么?

想要解决的一些问题

  1. 刷新页面数据不丢失:因为数据都保存在内存中,所以刷新之后自然不存在了
  2. URL 复制给其他人数据不丢失:因为数据没有持久化到 URL 上,也没有根据 URL 上的数据进行初始化,所以复制给别人的 URL 当然会丢失数据(搜索条件之类)
  3. 页面返回数据不丢失:因为数据都保存在内存中,所以跳转到其他路由再跳转回来数据当然不会存在了

那么,先谈一下每个问题的解决方案

  1. 刷新页面数据不丢失
    • 将数据序列化到本地,例如 localStorage 中,然后在刷新后获取一次
    • 将数据序列化到 URL 上,每次加载都从 URL 上获取数据
  2. URL 复制给其他人数据不丢失
    • 只能将数据序列化到 URL 上
  3. 页面返回数据不丢失
    • 将数据放到 vuex 中,并且在 URL 上使用 key 进行标识
    • 将数据序列化到 URL 上,并且不新增路由记录
    • 使用 vue-router 的缓存 keep-alive

在了解了这么多的解决方案之后,吾辈最终选择了兼容性最好的 URL 保存数据,它能同时解决 3 个问题。然而,很遗憾的是,这似乎并没有很多人讨论这个问题,或许,这个问题本应该是默认就需要解决的,亦或是 SPA 网站真的很少关心这些了。

虽说如此,吾辈还是找到了一些讨论的 StackOverflow: How to hold URL query params in Vue with Vue-Router

思路

一个基本的思路是能够确定的

  1. 在组件创建时,从 URL 获取数据并为需要的数据进行初始化
  2. 在这些数据变化时,及时将数据序列化到 URL 上

思路图

然后,再次出现了一个分歧点,到底要不要绑定 Vue?

  1. 不绑定 vue 手动监听对象变化并将对象的变化响应到 URL 上
    不绑定 vue
  2. 绑定 vue 并使用它的生命周期 created, beforeRouteUpdate 与监听器 watch
    绑定 vue

那么,两者有什么区别呢?

思路 不绑定 vue 绑定 vue
优点 非框架强相关,理论上可以通用 Vue/React 不需要手动实现 URL 的几种序列化模式,可以预见至少有两种:HTML 5 History/Hash
没有 vue/vue-router 的历史包袱 不需要手动实现数据监听/响应(虽然现在已然不算难了)
可以不管 vue-router 实现 URL 动态设置,可以自动优雅降级 灵活性很强,实现比较好的封装之后使用成本很低
缺点 没有包袱,但同时没有基础,序列化/数据监听都需要手动实现 存在历史包袱,vue/vue-router 的怪癖一点都绕不过去
灵活性不足,只能初始化一次,需要/不需要序列化的数据分割也相当有挑战 依赖 vue/vue-router,在其更新之时也必须跟着更新
不绑定 vue 意味着与 vue 不可能完美契合 无法通用,在任何一个其他框架(React)上还要再写一套

最终,在这个十字路口反复踌躇之后,吾辈选择了更加灵活、成本更低的第二种解决方案。

问题

已解决

  • 序列化数据到 URL 上导致路由记录会随着改变增加
  • 即时序列化数据到 URL 上不现实

    这里吾辈对 yarn 进行了考察发现其也是异步更新 URL

  • 序列化到 URL 上时导致的死循环,序列化数据到 URL 上 => 路由更新触发 => 初始化数据到 URL 上 => 触发数据改变 => 序列化数据到 URL 上。。。
  • 同一个路由携带不同的查询参数的 URL 直接在地址栏输入回车一次不会触发页面数据更新
  • URL 最大保存数据 IE 最多支持 2083 长度的 URL,换算为中文即为 231 个,所以不能作为一种通用方式进行
  • Vue 插件不能动态混入,而是在各个生命周期中判断是否要处理的

仍遗留

  • JSON 序列化的数据长度较 query param 更大

下面是具体实现及代码,不喜欢的话可以直接跳到最下面的 总结

实现

GitHub

基本尝试

首先,尝试不使用任何封装,直接在 created 生命周期中初始化并绑定 $watch

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
<template>
<div class="form1">
<div>
<label for="keyword">搜索名:</label>
<input type="text" v-model="form.keyword" id="keyword" />
</div>
<div>
<input
type="checkbox"
v-model="form.hobbyList"
id="anime"
value="anime"
/>
<label for="anime">动画</label>
<input type="checkbox" v-model="form.hobbyList" id="game" value="game" />
<label for="game">游戏</label>
<input
type="checkbox"
v-model="form.hobbyList"
id="movie"
value="movie"
/>
<label for="movie">电影</label>
</div>
<p>
{{ form }}
</p>
</div>
</template>

<script>
export default {
name: 'Form1',
data() {
return {
form: {
keyword: '',
hobbyList: [],
},
}
},
created() {
const key = 'qb'
const urlData = JSON.parse(this.$route.query[key] || '{}')
Object.assign(this.form, urlData.form)
this.$watch(
'form',
function(val) {
urlData.form = val
this.$router.replace({
query: {
...this.$route.query,
[key]: JSON.stringify(urlData),
},
})
},
{
deep: true,
},
)
},
}
</script>

分离通用性函数

然后,便是将之分离为单独的函数,方便在所有组件中进行复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 初始化一些数据需要序列化/反序列化到 url data 上
* @param exps 监视的数据的表达式数组
*/
function initUrlData(exps) {
const key = 'qb'
const urlData = JSON.parse(this.$route.query[key] || '{}')
exps.forEach((exp) => {
Object.assign(this[exp], urlData[exp])
this.$watch(
exp,
function (val) {
urlData[exp] = val
this.$router.replace({
query: {
...this.$route.query,
[key]: JSON.stringify(urlData),
},
})
},
{
deep: true,
},
)
})
}

使用起来需要在 created 生命中调用

1
2
3
4
5
export default {
created() {
initUrlData.call(this, ['form'])
},
}

处理深层监听

如果需要监听的值不是 data 下的顶级字段,而是深层字段的话,便不能直接使用 [] 进行取值和赋值了,而是需要实现支持深层取值/赋值的 get/set。而且,深层监听也意味着一般不会是对象,所以也不能采用 Object.assign 进行合并。

例如需要监听 page 对象中的 offset, size 两字段

首先,需要编写通用的 get/set 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* 解析字段字符串为数组
* @param str 字段字符串
* @returns 字符串数组,数组的 `[]` 取法会被解析为数组的一个元素
*/
function parseFieldStr(str) {
return str
.split(/[\\.\\[]/)
.map((k) => (/\]$/.test(k) ? k.slice(0, k.length - 1) : k))
}

/**
* 安全的深度获取对象的字段
* 注: 只要获取字段的值为 {@type null|undefined},就会直接返回 {@param defVal}
* 类似于 ES2019 的可选调用链特性: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/%E5%8F%AF%E9%80%89%E9%93%BE
* @param obj 获取的对象
* @param fields 字段字符串或数组
* @param [defVal] 取不到值时的默认值,默认为 null
*/
export function get(obj, fields, defVal = null) {
if (typeof fields === 'string') {
fields = parseFieldStr(fields)
}
let res = obj
for (const field of fields) {
try {
res = Reflect.get(res, field)
if (res === undefined || res === null) {
return defVal
}
} catch (e) {
return defVal
}
}
return res
}

/**
* 安全的深度设置对象的字段
* 注: 只要设置字段的值为 {@type null|undefined},就会直接返回 {@param defVal}
* 类似于 ES2019 的可选调用链特性: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Operators/%E5%8F%AF%E9%80%89%E9%93%BE
* @param obj 设置的对象
* @param fields 字段字符串或数组
* @param [val] 设置字段的值
*/
export function set(obj, fields, val) {
if (typeof fields === 'string') {
fields = parseFieldStr(fields)
}
let res = obj
for (let i = 0, len = fields.length; i < len; i++) {
const field = fields[i]
console.log(i, res, field, res[field])
if (i === len - 1) {
res[field] = val
return true
}
res = res[field]
console.log('res: ', res)
if (typeof res !== 'object') {
return false
}
}
return false
}

然后,是替换赋值操作,将之修改为一个专门的函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
/**
* 为 vue 实例上的字段进行深度赋值
*/
function setInitData(vm, exp, urlData) {
const oldVal = get(vm, exp, null)
const newVal = urlData[exp]
if (typeof oldVal === 'object' && newVal !== undefined) {
Object.assign(get(vm, exp), newVal)
} else {
set(vm, exp, newVal)
}
}

/**
* 初始化一些数据需要序列化/反序列化到 url data 上
* @param exps 监视的数据的表达式数组
*/
function initUrlData(exps) {
const key = 'qb'
const urlData = JSON.parse(this.$route.query[key] || '{}')
exps.forEach((exp) => {
setInitData(this, exp, urlData)
this.$watch(
exp,
function (val) {
urlData[exp] = val
this.$router.replace({
query: {
...this.$route.query,
[key]: JSON.stringify(urlData),
},
})
},
{
deep: true,
},
)
})
}

这样,便能单独监听对象中的某个字段了。

1
initUrlData.call(this, ['form.keyword'])

参考:lodash 的函数 get/set

使用防抖避免触发过快

但目前而言每次同步都是即时的,在数据量较大时,可能会存在一些问题,所以使用防抖避免每次数据更新都即时同步到 URL 上。

首先,实现一个简单的防抖函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/**
* 函数去抖
* 去抖 (debounce) 去抖就是对于一定时间段的连续的函数调用,只让其执行一次
* 注: 包装后的函数如果两次操作间隔小于 delay 则不会被执行, 如果一直在操作就会一直不执行, 直到操作停止的时间大于 delay 最小间隔时间才会执行一次, 不管任何时间调用都需要停止操作等待最小延迟时间
* 应用场景主要在那些连续的操作, 例如页面滚动监听, 包装后的函数只会执行最后一次
* 注: 该函数第一次调用一定不会执行,第一次一定拿不到缓存值,后面的连续调用都会拿到上一次的缓存值。如果需要在第一次调用获取到的缓存值,则需要传入第三个参数 {@param init},默认为 {@code undefined} 的可选参数
* 注: 返回函数结果的高阶函数需要使用 {@see Proxy} 实现,以避免原函数原型链上的信息丢失
*
* @param action 真正需要执行的操作
* @param delay 最小延迟时间,单位为 ms
* @param init 初始的缓存值,不填默认为 {@see undefined}
* @return function(...[*]=): Promise<any> {@see action} 是否异步没有太大关联
*/
export function debounce(action, delay, init = null) {
let flag
let result = init
return function (...args) {
return new Promise((resolve) => {
if (flag) clearTimeout(flag)
flag = setTimeout(
() => resolve((result = action.apply(this, args))),
delay,
)
setTimeout(() => resolve(result), delay)
})
}
}

$watch 中的函数用 debounce 进行包装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 初始化一些数据需要序列化/反序列化到 url data 上
* @param exps 监视的数据的表达式数组
*/
function initUrlData(exps) {
const key = 'qb'
const urlData = JSON.parse(this.$route.query[key] || '{}')
exps.forEach((exp) => {
setInitData(this, exp, urlData)
this.$watch(
exp,
debounce(function (val) {
urlData[exp] = val
this.$router.replace({
query: {
...this.$route.query,
[key]: JSON.stringify(urlData),
},
})
}, 1000),
{
deep: true,
},
)
})
}

引用:掘金:7 分钟理解 JS 的节流、防抖及使用场景
参考:lodash 的函数 debounce

处理路由不变但 query 修改的问题

接下来,就需要处理一种小众,但确实存在的场景了。

  • 同一个组件被多个路由复用,这些路由仅仅只是一个 path param 改变了。例如 标签页
  • 用户复制 URL 之后,发现其中的查询关键字错了,于是修改了关键字之后又复制了一次,而粘贴两次路由相同 query param 不同的 URL 是不会重新创建组件的

首先确定基本的思路:在路由改变但组件没有重新创建时将 URL 上的数据为需要的数据进行初始化

1
2
3
4
5
6
7
8
9
10
11
/**
* 在组件被 vue-router 路由复用时,单独进行初始化数据
* @param exps 监视的数据的表达式数组
* @param route 将要改变的路由对象
*/
function initUrlDataByRouteUpdate(exps, route) {
const urlData = JSON.parse(route.query[key] || '{}')
exps.forEach((exp) => {
setInitData(this, exp, urlData)
})
}

在 vue 实例的生命周期 beforeRouteUpdate, beforeRouteEnter 重新初始化 data 中的数据

1
2
3
4
5
6
7
8
9
export default {
beforeRouteUpdate(to, from, next) {
initUrlDataByRouteUpdate.call(this, ['form'], to)
next()
},
beforeRouteEnter(to, from, next) {
next((vm) => initUrlDataByRouteUpdate.call(vm, ['form'], to))
},
}

真的以为问题都解决了么?并不然,打开控制台你会发现一些 vue router 的警告

1
vue-router.esm.js?8c4f:2051 Uncaught (in promise) NavigationDuplicated {_name: "NavigationDuplicated", name: "NavigationDuplicated", message: "Navigating to current location ("/form1/?qb=%7B%22…,%22movie%22,%22game%22%5D%7D%7D") is not allowed", stack: "Error↵    at new NavigationDuplicated (webpack-int…/views/Form1.vue?vue&type=script&lang=js&:222:40)"}

其实是因为循环触发导致的:序列化数据到 URL 上 => 路由更新触发 => 初始化数据到 URL 上 => 触发数据改变 => 序列化数据到 URL 上。。。,目前可行的解决方案是在 $watch 中判断数据是否与原来的相同,相同就不进行赋值,避免再次触发 vue-router 的 beforeRouteUpdate 生命周期。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 初始化一些数据需要序列化/反序列化到 url data 上
* @param exps 监视的数据的表达式数组
*/
function initUrlData(exps) {
const urlData = JSON.parse(this.$route.query[key] || '{}')
exps.forEach((exp) => {
setInitData(this, exp, urlData)
this.$watch(
exp,
debounce(function (val) {
urlData[exp] = val
if (this.$route.query[key] === JSON.stringify(urlData)) {
return
}
this.$router.replace({
query: {
...this.$route.query,
[key]: JSON.stringify(urlData),
},
})
}, 1000),
{
deep: true,
},
)
})
}

现在,控制台不会再有警告了。

封装起来

使用 Vue 插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import { debounce, get, set } from './common'

class VueUrlPersist {
/**
* 一些选项
*/
constructor() {
this.expListName = 'exps'
this.urlPersistName = 'qb'
}

/**
* 将 URL 上的数据初始化到 data 上
* 此处存在一个谬误
* 1. 如果对象不使用合并而是赋值,则处理 [干净] 的 URL 就会很棘手,因为无法感知到初始值是什么
* 2. 如果对象使用合并,则手动输入的相同路由不同参数的 URL 就无法处理
* 注:该问题已经通过在 watch 中判断值是否变化而解决,但总感觉还有莫名其妙的坑在前面等着。。。
* @param vm
* @param expOrFn
* @param urlData
*/
initVueData(vm, expOrFn, urlData) {
const oldVal = get(vm, expOrFn, null)
const newVal = urlData[expOrFn]
if (oldVal === undefined || oldVal === null) {
set(vm, expOrFn, newVal)
} else if (typeof oldVal === 'object' && newVal !== undefined) {
Object.assign(get(vm, expOrFn), newVal)
}
}
/**
* 在组件被 vue-router 路由复用时,单独进行初始化数据
* @param vm
* @param expOrFnList
* @param route
*/
initNextUrlData(vm, expOrFnList, route) {
const urlData = JSON.parse(route.query[this.urlPersistName] || '{}')
console.log('urlData: ', urlData)
expOrFnList.forEach((expOrFn) => {
this.initVueData(vm, expOrFn, urlData)
})
}

/**
* 在组件被 vue 创建后初始化数据并监听之,在发生变化时自动序列化到 URL 上
* 注:需要序列化到 URL 上的数据必须能被 JSON.stringfy 序列化
* @param vm
* @param expOrFnList
*/
initUrlData(vm, expOrFnList) {
const urlData = JSON.parse(vm.$route.query[this.urlPersistName] || '{}')
expOrFnList.forEach((expOrFn) => {
this.initVueData(vm, expOrFn, urlData)

vm.$watch(
expOrFn,
debounce(1000, async (val) => {
console.log('val 变化了: ', val)
urlData[expOrFn] = val

if (
vm.$route.query[this.urlPersistName] === JSON.stringify(urlData)
) {
return
}

await vm.$router.replace({
query: {
...vm.$route.query,
[this.urlPersistName]: JSON.stringify(urlData),
},
})
}),
{
deep: true,
},
)
})
}
install(Vue, options = {}) {
const _this = this
if (options.expListName) {
this.expListName = options.expListName
}
if (options.urlPersistName) {
this.urlPersistName = options.urlPersistName
}
Vue.prototype.$urlPersist = this

function initDataByRouteUpdate(to) {
const expList = this[_this.expListName]
if (Array.isArray(expList)) {
this.$urlPersist.initNextUrlData(this, expList, to)
}
}

Vue.mixin({
created() {
const expList = this[_this.expListName]
if (Array.isArray(expList)) {
this.$urlPersist.initUrlData(this, expList)
}
},
beforeRouteUpdate(to, from, next) {
initDataByRouteUpdate.call(this, to)
next()
},
beforeRouteEnter(to, from, next) {
next((vm) => initDataByRouteUpdate.call(vm, to))
},
})
}
}

export default VueUrlPersist

使用起来和其他的插件没什么差别

1
2
3
4
5
// main.js
import VueUrlPersist from './views/js/VueUrlPersist'

const vueUrlPersist = new VueUrlPersist()
Vue.use(vueUrlPersist)

在需要使用的组件中只要声明这个属性就好了。

1
2
3
4
5
6
7
8
9
10
11
12
export default {
name: 'Form2Tab',
data() {
return {
form: {
keyword: '',
sex: 0,
},
exps: ['form'],
}
},
}

然而,使用 vue 插件有个致命的缺陷:无论是否需要,都会为每个组件中都混入三个生命周期函数,吾辈没有找到一种可以根据实例中是否包含某个值而决定是否混入的方式。

使用高阶函数

所以,我们使用 高阶函数 + mixin 的形式看看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
import { debounce, get, set } from './common'

class VueUrlPersist {
/**
* 一些选项
*/
constructor({ key = 'qb' } = {}) {
this.key = key
}

/**
* 为 vue 实例上的字段进行深度赋值
*/
setInitData(vm, exp, urlData) {
const oldVal = get(vm, exp, null)
const newVal = urlData[exp]
//如果原值是对象且新值也是对象,则进行浅合并
if (
oldVal === undefined ||
oldVal === null ||
typeof oldVal === 'string' ||
typeof oldVal === 'number'
) {
set(vm, exp, newVal)
} else if (typeof oldVal === 'object' && typeof newVal === 'object') {
Object.assign(get(vm, exp), newVal)
}
}
/**
* 初始化一些数据需要序列化/反序列化到 url data 上
* @param vm vue 实例
* @param exps 监视的数据的表达式数组
*/
initUrlDataByCreated(vm, exps) {
const key = this.key
const urlData = JSON.parse(vm.$route.query[key] || '{}')
exps.forEach((exp) => {
this.setInitData(vm, exp, urlData)
vm.$watch(
exp,
debounce(function (val) {
urlData[exp] = val
if (vm.$route.query[key] === JSON.stringify(urlData)) {
return
}
vm.$router.replace({
query: {
...vm.$route.query,
[key]: JSON.stringify(urlData),
},
})
}, 1000),
{
deep: true,
},
)
})
}

/**
* 在组件被 vue-router 路由复用时,单独进行初始化数据
* @param vm vue 实例
* @param exps 监视的数据的表达式数组
* @param route 将要改变的路由对象
*/
initUrlDataByRouteUpdate(vm, exps, route) {
const urlData = JSON.parse(route.query[this.key] || '{}')
exps.forEach((exp) => this.setInitData(vm, exp, urlData))
}

/**
* 生成可以 mixin 到 vue 实例的对象
* @param exps 监视的数据的表达式数组
* @returns {{created(): void, beforeRouteEnter(*=, *, *): void, beforeRouteUpdate(*=, *, *): void}}
*/
generateInitUrlData(...exps) {
const _this = this
return {
created() {
_this.initUrlDataByCreated(this, exps)
},
beforeRouteUpdate(to, from, next) {
_this.initUrlDataByRouteUpdate(this, exps, to)
next()
},
beforeRouteEnter(to, from, next) {
console.log('beforeRouteEnter')
next((vm) => _this.initUrlDataByRouteUpdate(vm, exps, to))
},
}
}

/**
* 修改一些配置
* @param options 配置项
*/
config(options) {
Object.assign(this, options)
}
}
const vueUrlPersist = new VueUrlPersist()
const generateInitUrlData = vueUrlPersist.generateInitUrlData.bind(
vueUrlPersist,
)

export { vueUrlPersist, generateInitUrlData, VueUrlPersist }

export default vueUrlPersist

使用起来几乎一样简单

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { generateInitUrlData } from './js/VueUrlPersist'

export default {
name: 'Form1',
mixins: [generateInitUrlData('form')],
data() {
return {
form: {
keyword: '',
hobbyList: [],
},
}
},
}

看起来,使用高阶函数也没有比 Vue 插件麻烦太多。

总结

总的来说,虽然路途坎坷,不过这个问题还是很有趣的,而且确实能解决实际的问题,所以还是有研究价值的。


浅谈 Vue SPA 网站 URL 保存数据实践
https://blog.rxliuli.com/p/35841f3c9ebe46aa9c589a92ff9558a7/
作者
rxliuli
发布于
2020年2月2日
许可协议