多國內企業正積極開拓國際市場,如Shopee、阿里的Lazada、字節的TikTok、拼多多海外版Temu、以及服裝快消領域的Shein等。當國內市場存量業務達到峰值預期時,海外業務成為各公司未來收入增長的主要動力,因此,國際化已成為越來越重要的職業發展方向。
國內IT企業收入天花板: 「10億X2元X365天=7300億元」,也就是10億人口,企業每天賺取用戶2元,保持365天,就是單業務增長的營收天花板(大部分業務賺不到2元,用戶量也沒到10億)。比如視頻如果60元一個月那會員營收天花板就可以這么預估. 甚至比這個還低, 畢竟用戶會流失, 拉新也要成本, 運營成本是在遞增的。
國際化不僅僅是多語言文案適配這么簡單,而是一全套的工程化解決方案。筆者覺得更重要的是「從業人員需要具備全球視野,對多元文化有包容心和敬畏心理,同時知識面要求也較高」。比如,了解SEA、US、UK等常見地區的簡寫,尊重伊斯蘭教的齋月節等習俗。對于服務全球用戶的產品來說,對應產品的要求更加復雜,多樣性體現在不同的文化習俗差異上,其實即便在龐大的中國內部也存在南北差異。了解的越多越發現這個世界的“多樣性”。
蘋果鍵盤有很多型號不同型號的布局不一樣https://www.apple.com/shop/product/MK2A3J/A/magic-keyboard-japanese
apple-keyboard
那如何模仿蘋果造一把可以賣到世界各地的鍵盤?
其中2,3,4都是為產品的全球化服務
https://en.wikipedia.org/wiki/Internationalization_and_localization
globalization
產品面向全球用戶,需要做語言適配,針對不同國家地區的用戶提供對應語言的版本。本質是「文本替換」,也要考慮文本閱讀方向,比如阿拉伯語和希伯來語是從右到左。
可以看下Apple的做法,對不同國家地區提供不同服務
常見地區語言對應關系可以看 ISO 3166-1(https://baike.baidu.com/item/ISO%203166-1/5269555?fr=ge_ala)
MDN: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl
瀏覽器中的 Intl 是一個內置的 JavaScript 對象,用于國際化(Internationalization)支持。它提供了處理日期、時間、數字格式化以及貨幣和語言的能力,以便網站能夠根據用戶的語言和地區習慣來顯示內容。在 JavaScript 中,您可以使用 Intl 對象來執行以下操作:
全地球有N個民族,有的民族有自己的語言, 有的民族用其他國家民族傳遞過來的語言, 融化吸收然后發展自己的文字。
按照ISO標準(https://zh.m.wikipedia.org/wiki/ISO_639-1),語言可以用大類+小類表示, 比如「zh」就是漢語,是一個大類,而「zh-CN」就是簡體中文的縮寫, 新加坡華人眾多久了就有「zh-SG」, 表示的是新加坡使用的中文,其次還有「zh-HK/zh-TW和zh-Hant/zh-Hans」等等
語言聲明是三段式結構 [language]-[script]-[region] , 如zh-Hans-CN表示中國地區簡體中文, zh-Hant表示所有中文繁體
Language Code Table(http://www.lingoes.net/zh/translator/langcode.htm)
一起來看下蘋果官網是如何適配多國語言的
澳門apple https://www.apple.com/mo/
香港apple
英文 https://www.apple.com/hk/en/
中文 https://www.apple.com/hk/
中國大陸地區apple https://www.apple.com.cn/
臺灣apple https://www.apple.com/tw/
新加坡apple https://www.apple.com/sg/
日本apple https://www.apple.com/jp/
可以看到有的是根域名下通過ISO地區的path比如**/hk/**這樣來區分的,有的是直接換域名,比如中國大陸地區
按照普通的中文和英文順序,都是LTR,上到下,都是世界范圍通用的
而ar阿拉伯語, ur烏都語, he希伯來語都是特殊的從右到左, 即RTL的一般會通過標簽的dir屬性標識, 比如下面的解釋HTML dir Attribute(https://www.w3schools.com/tags/att_global_dir.asp)
HEBREW是指希伯來語,這是一種在以色列廣泛使用的語言,也是猶太教的宗教經典文本的原始語言。它屬于阿夫羅亞細亞語系,有著悠久的歷史和文化價值。「希伯來語有其獨特的書寫系統,從右向左書寫。」上圖的概念很少人普及, 因為非國際化產品不需要多語言, 做需要支持海外業務和全球應用的同學可以多了解下. 傳統的英文, 中文簡體, 拉丁文等都是上圖LATIN的閱讀順序, 如果用上「top, 下bottom, 左left, 右right」代表我們的習慣, 也就是「Z」這樣的順序. 即行到行是從上到下的順序, 行內閱讀順序是從左到右.
文檔流和閱讀順序
即left→right, top→bottom的順序,有主次分別,left→right的優先級高于top→bottom
而Web標準對其定義是下面這樣的
講個笑話, 古代書籍就是按照 writing-mode: vertical-rl 排版的
joke
比如 margin: left 或者 text-align: left 在多語言場景都是不合適的,你的左右不是其他人的左右。
而應該用 margin-inline-start 和 text-align: start 替代,即inline軸和block軸
// 下面兩兩相等,請拋棄left/right/top/bottom等屬性
// https://web.dev/learn/css/logical-properties/#terminology
margin-left: 1px
margin-inline-start: 1px
margin-right: 1px
margin-inline-end: 1px
margin-top: 1px
margin-block-start: 1px
margin-bottom: 1px
margin-block-end: 1px
text-align: left
text-align: start
text-align: right
text-align: end
max-width: 100px
max-inline-width: 100px
max-inline-size: 150px
max-height: 100px
max-block-width: 100px
padding-left: 1px
padding-inline-start: 1px
padding-top: 1px
padding-block-start: 1px
top: 0.2em;
inset-block-start: 0.2em;
bottom: 0.2em;
inset-block-end: 0.2em;
left: 2px;
inset-inline-start: 2px;
right: 2px;
inset-inline-end: 2px;
border-bottom: 1px solid red;
border-block-end: 1px solid red;
border-bottom-right-radius: 1em;
border-end-end-radius: 1em;
height: 160px;
block-size: 160px;
width: 160px;
inline-size: 160px;
也可以看下面的例子
如上兩個例子通過margin-inline-start等屬性,再在html元素上添加 dir: rtl 就可以實現多語言的閱讀順序兼容
由此, 常見的布局也會更新為以下形式,常見的物理盒模型用于尺寸計算, 邏輯盒模型用于國際化處理
盒子模型
上面寫了文檔有inline and block flow,對應english的left和right,top和bottom。而 writing-mode 可以修改content-flows,比如下面的值
/* 關鍵值 */
writing-mode: horizontal-tb;
writing-mode: vertical-rl;
writing-mode: vertical-lr;
可以這么理解 writing-mode: horizontal-tb ,前面的horizontal/vertical是指的inline軸的方向,
https://codepen.io/manfredhu/pen/xxWdpaK
視口寬高viewport在這里也有特殊含義. 比如寬高vw和vh也被取代,用 vi(viewport inline) 和 vb(viewport block)替代
1%寬度=1vw=1vi 1%高度=1vh=1vb
DOM的API可以通過「Element.scrollLeft」獲取到元素滾動的距離,下圖是一個實際例子
scrollLeft的rtl
這里在最后做了一個遮罩(綠色邊框區域),內部藍色部分類似一個走馬燈,通過overflow:hidden將藍色高亮部分超出的區域遮住
當藍色部分滾動到最后,綠色遮罩隱藏,達到一個遮蓋,滾動到最后消失的效果,代碼如下
const ref=document.querySelector('.tiktok-table__container') // 父節點,藍色區域
const ref2=document.querySelector('.tiktok-table__container > table') // 子節點,表格區域
const bufferWidth=30 // 留一點buffer空間
if (ref && ref2 && ref.clientWidth + ref.scrollLeft >=ref2.clientWidth - bufferWidth) {
// 滾動到最后隱藏綠色遮罩
setTableRightMask(false)
} else {
setTableRightMask(true)
}
但是在RTL下,神奇的事情就發生了,scrollLeft居然是負數
這是因為RTL的實現是通過HTML標簽增加屬性 dir="rtl” 實現的,會將文檔完全翻轉過來,所以scrollLeft就會是負數。因為此時(0, 0)這個原點已經是表格右邊了
解決方法也很簡單,取絕對值唄,這樣就忽略了方向的影響
根據ISO標準對全球國家地區進行劃分https://en.wikipedia.org/wiki/ISO_3166-2. 如 "US" 表示美國,"CN" 表示中國. 還有常見的如「zh-CN, en-US, en-GB等」
舉個說下Intl API對于locale的定義
const korean=new Intl.Locale('ko', {
script: 'Kore',
region: 'KR',
hourCycle: 'h23',
calendar: 'gregory',
});
const japanese=new Intl.Locale('ja-Jpan-JP-u-ca-japanese-hc-h12');
console.log(korean.baseName, japanese.baseName);
// Expected output: "ko-Kore-KR" "ja-Jpan-JP"
可以看到Intl. Locale就是把傳入的字符串拆解為 [language]-[script]-[region] 的組成.
const japanese=new Intl.Locale('ja-Jpan-JP-u-ca-japanese-hc-h12');
const japanese2=new Intl.Locale('ja-Jpan-JP-u-hc-h12-ca-japanese');
-u (unicode)可以理解為額外擴展插件, 插件系統支持以下擴展. 如上使用calendar擴展和hourCycle擴展
calendarca (extension)caseFirstkf (extension)collationco (extension)hourCyclehc (extension)numberingSystemnu (extension)numerickn (extension)
calendar:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar)
caseFirst:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/caseFirst)
collation:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/collation)
hourCycle:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle)
numberingSystem:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numberingSystem)
numeric:https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/numeric)
<html lang="en-US">
<!-- 說明頁面語言是美式英文 -->
<p>I felt some <span lang="de">schadenfreude</span>.</p> <!-- lang不是只有html標簽才有, 其他標簽也可以添加 -->
<a href="/path/to/german/version" hreflang="de" lang="de">Deutsche Version</a> <!-- a標簽上可以用lang表示顯示的文本的語言, 也可以用hreflang表示跳轉頁面的語言 -->
語言屬性通常代表語言標識符的主要部分。它標識了用于表達語言的基本信息。例如,在 BCP 47 標準中,語言標識符通常包含了語言的主要代碼部分,例如 "en" 代表英語,"es" 代表西班牙語。
const locales=['en', 'de', 'ja'];
const displayNames=new Intl.DisplayNames('en-US', { type: 'language' });
locales.forEach(locale=> console.log(displayNames.of(locale)));
// English
// German
// Japanese
如果已經有如上代碼, 再進一步給這些內容添加樣式是非常簡單的, 我們可以使用CSS的選擇器. 如 [lang|="fr"] 或者 :lang(fr)
[lang|="fr"] 選擇屬性 lang=fr 或者lang屬性以fr開頭的元素, 如 lang=fr-CA
腳本屬性是語言標識符的可選部分,表示使用的書寫系統或文字的風格。這是一個輔助信息,用于更精確地表示特定語言的書寫習慣。例如,"Hans" 代表簡體中文,"Latn" 代表拉丁文。
script的of支持傳入BCP47規范的二字碼(https://en.wikipedia.org/wiki/IETF_language_tag), 如zh
const scriptNames=new Intl.DisplayNames('en-US', { type: 'script' });
console.log(scriptNames.of('Hans')); // output:Simplified
console.log(scriptNames.of('Hant')); // output:Traditional
console.log(scriptNames.of('Latn')); // output:Latin
const scriptNames=new Intl.DisplayNames('zh-CN', { type: 'script' });
console.log(scriptNames.of('Hans')); // output:簡體
console.log(scriptNames.of('Hant')); // output:繁體
console.log(scriptNames.of('Latn')); // output:拉丁文
region 的of支持傳入https://en.wikipedia.org/wiki/ISO_3166-2 里的國家二字碼
const regionNamesInEnglish=new Intl.DisplayNames(['en'], { type: 'region' });
const regionNamesInTraditionalChinese=new Intl.DisplayNames(['zh-Hant'], { type: 'region' });
console.log(regionNamesInEnglish.of('US'));
// Expected output: "United States"
console.log(regionNamesInTraditionalChinese.of('US'));
// Expected output: "美國"
文本的閱讀順序聲明
可以通過html標簽的dir屬性設置
<html dir="ltr">
<html dir="rtl">
writing-mode: horizontal-tb;
writing-mode: vertical-lr;
writing-mode: vertical-rl;
文本行的布局方向, 以及塊的排列方向。如果想作用在整個文檔需要設置html標簽, 則全局生效
「第一個屬性horizontal/vertical指的是行塊的排列, 第二個屬性則是指文本內容的流向(content flows)」
形如 「?」 這種符號類,在多語言下是不一樣的
比如ar阿拉伯語和ur烏爾都斯語問號是RTL的,即 「?」(https://zh.m.wiktionary.org/wiki/%D8%9F)是OK的
而he希伯來語是LTR的,即 「?」 是OK的
是不是很神奇?一個問號也能玩出花來
線索管理頁-英文和阿拉伯語
常見的需要RTL的語言有下面這些
const rtlLangs=[
'ar', // 阿拉伯語
'ur', // 巴基斯坦
'he', // 以色列
'he-IL', // 希伯來語(以色列)
'fa-IR', // 波斯語(伊朗)
'ps' // 帕斯圖語
];
l10n本地化的一個比較多工作量的部分是文本的翻譯, 一種文本到N種文本的翻譯需要引入本地化團隊. 技術實現上選擇也很多
通過 key: text 映射, 比如 t('key') 最后程序跑出來就是text文案, 這種方式不會依賴其他東西, 跟普通網頁一樣內容都是CDN文件. 缺點是文案做為靜態資源需要用戶額外獲取, 如果處理不好替換錯誤就展示 key 內容而不是
以下例子以Vue為例, 配置如「en.json, fr.json」等等的靜態配置文案, 打包嵌入CDN的JS文件里
Vue i18n example:https://codesandbox.io/p/sandbox/o7n6pkpwoy?file=%2Fstore.js%3A10%2C14
程序運行時通過接口拿文案,可以通過html標簽添加query參數 lang=xxx 標記頁面語言, 或者cookie標記語言選擇
加載翻譯的腳本, 在切換語言的時候替換掉加載的文本。好處是加載的腳本是當前語言所需要的, 不會有其他語言的冗余. 缺點是依賴一個翻譯服務, 如果翻譯服務宕機了網頁就不能正常訪問了
User -> gateway -> SSR -> i18n cache -> read-time translation services(實時翻譯服務)
DevPal - ICU message editor:https://devpal.co/icu-message-editor/?data=I%20have%20%7Bnum%2C%20plural%2C%20one%7B%7Bnumber%7D%20%23%20apple%7D%20other%7B%23%20apples%7D%7D%2Cbut%20it%27s%20too%20small%0A
ICU語法即通用的有if-else邏輯的DSL,如下DSL可以根據傳入的值換取不同的表示,常用于國際化業務
I have {num, plural, one{{number} # apple} other{# apples}},but it's too small
如果你用過vim一定知道w(word)可以移動到下個單詞, 英文里把文本分為單詞、句子和段落,同理中文也是
const segmenter=new Intl.Segmenter('en-US', { granularity: 'word' });
const text='This is a sample text for demonstration purposes.';
// 使用 Segmenter 對文本進行分割
const segments=[...segmenter.segment(text)];
console.log(segments);
// 0: {segment: 'This', index: 0, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 1: {segment: ' ', index: 4, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 2: {segment: 'is', index: 5, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 3: {segment: ' ', index: 7, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 4: {segment: 'a', index: 8, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 5: {segment: ' ', index: 9, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 6: {segment: 'sample', index: 10, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 7: {segment: ' ', index: 16, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 8: {segment: 'text', index: 17, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 9: {segment: ' ', index: 21, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 10: {segment: 'for', index: 22, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 11: {segment: ' ', index: 25, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 12: {segment: 'demonstration', index: 26, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 13: {segment: ' ', index: 39, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
// 14: {segment: 'purposes', index: 40, input: 'This is a sample text for demonstration purposes.', isWordLike: true}
// 15: {segment: '.', index: 48, input: 'This is a sample text for demonstration purposes.', isWordLike: false}
Intl. Segmenter分段器可以把句子, 段落, 文章等按照配置切割為不同的segment數組, 結構類似正則, 有segment屬性
再舉個例子, 中文語境下「真的」其實是一個詞
// 創建分段器,指定語言環境和分段類型為'word'
const segmenter=new Intl.Segmenter(['en', 'zh'], { granularity: 'word' });
// 要分割的字符串
const text='Hello世界Hello world';
// 使用分段器分割字符串
const segments=segmenter.segment(text);
// 遍歷并打印每個分段的結果
for (const segment of segments) {
console.log(`Segment: ${segment.segment}, Index: ${segment.index}, IsWordLike: ${segment.isWordLike}`);
}
// Segment: Hello, Index: 0, IsWordLike: true
// Segment: 世界, Index: 5, IsWordLike: true
// Segment: Hello, Index: 7, IsWordLike: true
// Segment: , Index: 12, IsWordLike: false
// Segment: world, Index: 13, IsWordLike: true
const str="我真的很強, 強哥的強";
const segmenterJa=new Intl.Segmenter("zh-CN", { granularity: "word" });
const segments=segmenterJa.segment(str);
console.log(Array.from(segments));
// 0: {segment: '我', index: 0, input: '我真的很強, 強哥的強', isWordLike: true}
// 1: {segment: '真的', index: 1, input: '我真的很強, 強哥的強', isWordLike: true}
// 2: {segment: '很', index: 3, input: '我真的很強, 強哥的強', isWordLike: true}
// 3: {segment: '強', index: 4, input: '我真的很強, 強哥的強', isWordLike: true}
// 4: {segment: ',', index: 5, input: '我真的很強, 強哥的強', isWordLike: false}
// 5: {segment: ' ', index: 6, input: '我真的很強, 強哥的強', isWordLike: false}
// 6: {segment: '強', index: 7, input: '我真的很強, 強哥的強', isWordLike: true}
// 7: {segment: '哥', index: 8, input: '我真的很強, 強哥的強', isWordLike: true}
// 8: {segment: '的', index: 9, input: '我真的很強, 強哥的強', isWordLike: true}
// 9: {segment: '強', index: 10, input: '我真的很強, 強哥的強', isWordLike: true}
國際化會有時區劃分問題, 時區產生于太陽下地球自轉導致的晝夜交替. 而全球不同國家地區當地時間與UTC時間是不一致的. 全球大部分人都可以說自己早上起床, 晚上睡覺. 上下文是通的. 但是這個早上的時間根據UTC來定義是不一樣的
時間的往事--記一次與夏令時的斗智斗勇:https://jiangyixiong.top/2021/05/25/%E6%97%B6%E9%97%B4%E7%9A%84%E5%BE%80%E4%BA%8B%E2%80%94%E2%80%94%E8%AE%B0%E4%B8%80%E6%AC%A1%E4%B8%8E%E5%A4%8F%E4%BB%A4%E6%97%B6%E7%9A%84%E6%96%97%E6%99%BA%E6%96%97%E5%8B%87
GMT 標準時間 全球時區查詢:https://time.artjoey.com/cn
通過NTP協議(https://zh.wikipedia.org/wiki/%E7%B6%B2%E8%B7%AF%E6%99%82%E9%96%93%E5%8D%94%E5%AE%9A), 讓計算機在全球網絡里保持時間一致
東八區={CST(中國標準時),SGT(新加坡時間),AWST(澳洲西部標準時)... }
// 所在地區的時區標識符, 如 America/New_York
const timeZone=new Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log("用戶時區偏移:" + timeZone); // 用戶時區偏移:Asia/Shanghai
// 獲取本地時間與UTC時間偏移值,最小單位是分鐘. 如"-480", 表示-8小時. 其中正負表示UTC前后, 如美國東部時間是UTC-5, 中國北京時間是UTC+8
const date=new Date();
const timeZoneOffset=date.getTimezoneOffset();
console.log("時區偏移:" + timeZoneOffset); // 時區偏移:-480
Intl是新的瀏覽器API, 與Math類似是全局靜態對象, 專門用于處理國際化和本地化業務. 其下的DateTimeFormat可以處理時間相關國際化問題
DST (Daylight saving time),日光節約時,夏令時/冬令時等等名稱。「它會在每年春天的某一天將時鐘向后撥一小時,又在秋天的某一天將時鐘向前撥動一個小時。」非國際化業務很少遇到這個情況,主要因為「中國不實行夏令時/冬令時。」
2021-03-14 01:59:59 GMT-08:00(太平洋標準時間,PST)
2021-03-14T01:59:59.000-08:00(ISO格式表示)
2021-03-14T09:59:59.000Z(轉換為UTC時間并以ISO格式表示)
// 下一秒時間突變
2021-03-14 03:00:00 GMT-07:00(太平洋夏令時間,PDT)
2021-03-14T03:00:00.000-07:00(ISO格式表示)
2021-03-14T10:00:00.000Z(轉換為UTC時間并以ISO格式表示)
// 原始時間字符串
const timeString="2021-03-14T09:59:59.000Z";
// 將時間字符串轉換為 Date 對象
const date=new Date(timeString);
const pstOutput=date.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour12: false });
console.log(pstOutput); // 3/14/2021, 01:59:59
// 獲取時間戳
const timestamp=date.getTime();
// 增加1秒
const newTimestamp=timestamp + 1000;
// 創建新的 Date 對象并格式化為 PDT 時間
const newDate=new Date(newTimestamp);
const pdtOutput=newDate.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour12: false });
console.log(pdtOutput); // 3/14/2021, 03:00:00
dayjs: https://day.js.org/docs/zh-CN/i18n/i18n
國際化支持 https://github.com/iamkun/dayjs/tree/dev/src/locale
原理:通過拉取多語言文案輸出不同的formated日期時間字符串
可以看這個demo
Days of the week:https://codesandbox.io/s/dayjs-dynamic-locale0import-forked-wnk2zq?file=/src/index.js
因我本地系統設置了每周第一天為星期日
const date=new Date();
const formattedDate=new Intl.DateTimeFormat('en-US').format(date);
console.log(formattedDate); // 10/29/2023
const formattedDate=new Intl.DateTimeFormat('zh-CN').format(date);
console.log(formattedDate); // 2023/10/29
// 創建 DateTimeFormat 對象,并指定語言和地區
const dateFormatterCN=new Intl.DateTimeFormat('zh-CN', {
year: 'numeric',
month: 'long', // 使用完整的月份名稱
day: 'numeric',
});
console.log(dateFormatterCN.format(new Date('2024-04-28'))); // 2024年4月28日
const dateFormatterUS=new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long', // 使用完整的月份名稱
day: 'numeric',
});
console.log(dateFormatterUS.format(new Date('2024-04-28'))); // April 28, 2024
「Intl.RelativeTimeFormat」 是 JavaScript 中的國際化 API,用于格式化相對時間,例如“1 小時前”或“2 天后”。這個 API 可以根據不同的語言和地區設置,以自然語言的方式呈現相對時間,使應用程序能夠更好地適應多語言環境。
const rtf1=new Intl.RelativeTimeFormat('zh', { style: 'short' });
console.log(rtf1.format(3, 'quarter'));
// Expected output: "3個季度后"
console.log(rtf1.format(-1, 'day'));
// Expected output: "1天前"
const rtf2=new Intl.RelativeTimeFormat('jp', { numeric: 'auto' });
console.log(rtf2.format(2, 'day'));
// Expected output: "后天"
我們知道中文語境是一萬以上可以縮寫為1萬, 或者是 1 0000. 也就是4位數字. 比如 1 2345 6789或者1’2345’6789(’是萬位分隔符)可以一眼看出來是一億兩千三百四十五萬六千七百八十九. 而如果是123, 456, 789可能很多人會愣很久重新數才知道是多少. 但是現在很多銀行APP都在推跟歐美一樣的屬于后者的千位分隔符. 可以看這篇討論覺得寫的在理
設計產品時,你是如何掉入從眾的陷阱中的?– 人人都是產品經理:https://www.woshipm.com/pd/1500589.html)
類似以上例子可以再看下面的舉例, 可以發現在德語和法語下, 千分位分隔符分別是.和 (空格)
const number=1234567.89;
const formattedNumber=new Intl.NumberFormat('zh-CN').format(number);
console.log(formattedNumber); // 1,234,567.89
const number=1234567.89;
const formattedNumber=new Intl.NumberFormat('en-US').format(number);
console.log(formattedNumber); // 1,234,567.89
const number=1234567.89;
const formattedNumber=new Intl.NumberFormat('de-DE').format(number);
console.log(formattedNumber); // 1.234.567,89
const number=1234567.89;
const formattedNumber=new Intl.NumberFormat('fr-FR').format(number);
console.log(formattedNumber); // 1 234 567,89
英文復數是要加s的, 比如apples
const numbers=[1, 2, 5, 10, 100];
for (const number of numbers) {
const pluralRules=new Intl.PluralRules('en-US'); // 使用英語環境
const pluralForm=pluralRules.select(number);
console.log(`In English, ${number} item${pluralForm !=='one' ? 's' : ''}.`);
}
// In English, 1 item.
// In English, 2 items.
// In English, 5 items.
// In English, 10 items.
// In English, 100 items.
再比如順序, 第一第二第三, 英文分別為 first, second, third, fourth, fifth. 聰明的你一定發現規律了. 除了123后面就是數字+th. 簡寫是1st 2nd. 根據下表可以發現規律
數字英文第N1One1st2Two2nd3Three3rd4Four4th10Ten10th11Eleven11th12Twelve12th13Thirteen13th20Twenty20th21Twenty-one21st30Thirty22nd31Thirty-one21st100One hundred100th
const enOrdinalRules=new Intl.PluralRules("en-US", { type: "ordinal" });
const suffixes=new Map([
["one", "st"],
["two", "nd"],
["few", "rd"],
["other", "th"],
]);
const formatOrdinals=(n)=> {
const rule=enOrdinalRules.select(n);
const suffix=suffixes.get(rule);
return `${n}${suffix}`;
};
formatOrdinals(0); // '0th'
formatOrdinals(1); // '1st'
formatOrdinals(2); // '2nd'
formatOrdinals(3); // '3rd'
formatOrdinals(4); // '4th'
formatOrdinals(11); // '11th'
formatOrdinals(21); // '21st'
formatOrdinals(42); // '42nd'
formatOrdinals(103); // '103rd'
常見的整數分隔符號有千分位分隔, 比如 1000,000 也有萬位分隔比如 1000 0000 . 不同語言不一樣
常見的小數分隔符號 . , 比如 1000.00 . 不同語言不一樣
const number=1234567.89;
// 格式化為默認數字格式
const formattedNumber=new Intl.NumberFormat().format(number);
console.log(formattedNumber); // 輸出: 1,234,567.89
// 格式化為指定語言環境的數字格式
const formattedNumberDE=new Intl.NumberFormat('de-DE').format(number);
console.log(formattedNumberDE); // 輸出: 1.234.567,89
// 格式化為指定語言環境的數字格式
const formattedNumberFR=new Intl.NumberFormat('fr-FR').format(number);
console.log(formattedNumberFR); // 輸出: 1 234 567,89
const formattedNumberCN=new Intl.NumberFormat('zh-CN').format(number);
console.log(formattedNumberCN) // 輸出: 1,234,567.89
也可以通過參數配置控制小數部分最多/最少有多少位
const number=1234567.89123;
const formattedNumber=new Intl.NumberFormat('en-US', {
style: 'decimal', // 可選 'decimal' 表示常規數字格式
maximumFractionDigits: 3, // 小數部分最多顯示三位
}).format(number);
console.log(formattedNumber); // 輸出: 1,234,567.891
正常百分比是0-100數字+%, 但是法語環境百分比符號習慣是 '% '而不是'%', 多了一個空格
const percentage=0.75;
// 使用默認語言環境
const formattedPercentageDefault=new Intl.NumberFormat('fr-FR', {
style: 'percent'
}).format(percentage);
console.log(formattedPercentageDefault); // 輸出: '75 %'
// 使用指定語言環境
const formattedPercentageFR=new Intl.NumberFormat('fr-FR', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(percentage);
console.log(formattedPercentageFR); // 輸出: '75,00 %'
// 使用默認語言環境
const formattedPercentageUS=new Intl.NumberFormat('en-US', {
style: 'percent'
}).format(percentage);
console.log(formattedPercentageUS); // 輸出: '75%'
// 使用指定語言環境
const formattedPercentageCN=new Intl.NumberFormat('zh-CN', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(percentage);
console.log(formattedPercentageCN); // 輸出: '75.00%'
console.log(new Intl.NumberFormat('en-US', { notation: "compact" , compactDisplay: "short", maximumFractionDigits: 2 }).format(987654321)) // 987.65M
console.log(new Intl.NumberFormat('zh-CN', { notation: "compact" , compactDisplay: "short", maximumFractionDigits: 2 }).format(987654321)) // 9.88億
比如人民幣是 ¥ , 美元是 $ , 歐元 , 英鎊 £
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).formatToParts().filter(i=> i.type==='currency')[0].value // '$'
new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).formatToParts().filter(i=> i.type==='currency')[0].value // '¥'
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).formatToParts().filter(i=> i.type==='currency')[0].value // ''
用常見的幾個經濟體和身邊用的多的case舉例說明, 注意看輸出
// 美元 $是美元符號
const numberUSD=123456789.12;
const formattedNumberUSD=new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(numberUSD);
console.log(formattedNumberUSD); // $123,456,789.12
// 人民幣 ¥是人民幣符號
const numberCNY=123456789.12;
const formattedNumberCNY=new Intl.NumberFormat('zh-CN', { style: 'currency', currency: 'CNY' }).format(numberCNY);
console.log(formattedNumberCNY); // ¥123,456,789.12
// 歐元 是歐元符號
const numberEUR=123456789.12;
const formattedNumberEUR=new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(numberEUR);
console.log(formattedNumberEUR); // 123.456.789,12
// 日元
const numberJPY=123456789.12;
const formattedNumberJPY=new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(numberJPY);
console.log(formattedNumberJPY); // ¥123,456,789
// 英鎊 £是英鎊符號
const numberGBP=123456789.12;
const formattedNumberGBP=new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'GBP' }).format(numberGBP);
console.log(formattedNumberGBP); // £123,456,789.12
// 港幣
const numberHKD=123456789.12;
const formattedNumberHKD=new Intl.NumberFormat('zh-HK', { style: 'currency', currency: 'HKD' }).format(numberHKD);
console.log(formattedNumberHKD); // HK$123,456,789.12
// 韓元
const numberKRW=123456789.12;
const formattedNumberKRW=new Intl.NumberFormat('ko-KR', { style: 'currency', currency: 'KRW' }).format(numberKRW);
console.log(formattedNumberKRW); // ?123,456,789.12
貨幣的兼容性兜底可以用 Number.prototype.toLocaleString 實現, 也可以用formatjs提供的polyfill
// 美元 $是美元符號
const numberUSD=123456789.12;
const formattedNumberUSD=new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(numberUSD);
const formatttdNumberUSDByLocaleString=Number(numberUSD).toLocaleString('en-US', { style: 'currency', currency: 'USD' });
console.log(formattedNumberUSD); // $123,456,789.12
console.log(numberUSD.toLocaleString()) // 123,456,789.12
console.log(formatttdNumberUSDByLocaleString) // $123,456,789.12
比如美國是美元, 中國有人民幣. 可以直接格式化出來
currencyNames=new Intl.DisplayNames(["zh-Hans"], { type: "currency" });
console.log(currencyNames.of("USD")); // "美元"
console.log(currencyNames.of("EUR")); // "歐元"
console.log(currencyNames.of("TWD")); // "新臺幣"
console.log(currencyNames.of("CNY")); // "人民幣"
currencyNames=new Intl.DisplayNames(["zh-Hant"], { type: "currency" });
console.log(currencyNames.of("USD")); // "美元"
console.log(currencyNames.of("EUR")); // "歐元"
console.log(currencyNames.of("TWD")); // "新臺幣"
console.log(currencyNames.of("CNY")); // "人民幣"
常見的電話本, 地址簿排序. 不同語言因為字母轉換后排序不一致. 「Intl.Collator」 是 JavaScript 的國際化 API 之一,用于字符串比較和排序,以便在多語言環境中執行正確的排序操作。它允許你創建一個 「Collator」 對象,用于根據特定語言和區域設置執行字符串比較和排序,考慮到不同語言的差異。
console.log(['Z', 'a', 'z', '?'].sort(new Intl.Collator('de').compare));
// Expected output: Array ["a", "?", "z", "Z"]
console.log(['Z', 'a', 'z', '?'].sort(new Intl.Collator('sv').compare));
// Expected output: Array ["a", "z", "Z", "?"]
console.log(['Z', 'a', 'z', '?'].sort(new Intl.Collator('de', { caseFirst: 'upper' }).compare));
// Expected output: Array ["a", "?", "Z", "z"]
//創建一個Intl.Collator對象
const collator=new Intl.Collator('en-US', { sensitivity: 'base', usage: 'sort' });
// 可以看出以下輸出是按照拼音排序, guang jin mei ming tian yang
console.log(['今','天','陽','光', '明', '媚'].sort(new Intl.Collator('zh').compare)); // ['光', '今', '媚', '明', '天', '陽']
可以發現 options可以傳遞參數usage和sensitivity, 有如下取值
有如下的應用方式
const collator=new Intl.Collator('en-US', { sensitivity: 'base', usage: 'sort' }); //創建一個Intl.Collator對象
const result=collator.compare('apple', 'Banana');
console.log(result); // 根據配置輸出 -1(apple 在 Banana 前面)
我們知道英文字母默認按照ASCII排序, 而如果需要AaBb這樣排序只能自己寫排序回調
// 創建一個自定義Collator對象
const customCollator=new Intl.Collator('en-US', {
sensitivity: 'base',
usage: 'sort',
ignorePunctuation: true,
caseFirst: 'false',
});
// 自定義比較函數, 忽略空格并不區分大小寫
function customCompare(a, b) {
// 移除字符串中的空格并轉為小寫后再比較
const stringA=a.replace(/\\s/g, '').toLowerCase();
const stringB=b.replace(/\\s/g, '').toLowerCase();
if (stringA < stringB) {
return -1;
}
if (stringA > stringB) {
return 1;
}
return 0;
}
const data=['Apple', 'banana', 'cherry', 'apple pie', 'Banana Split', 'cherry tart'];
const data2=data.slice()
// 老方式: 使用sort回調排序
console.log(data.sort(customCompare)); // 輸出排序結果:['Apple', 'apple pie', 'banana', 'Banana Split', 'cherry', 'cherry tart']
// 新方式: 使用自定義Collator對象進行排序
console.log(data2.sort(customCollator.compare)); // 輸出排序結果:['Apple', 'apple pie', 'banana', 'Banana Split', 'cherry', 'cherry tart']
可以發現兩種方式結果一樣, 但是明顯Intl. Collator更加優雅, 是配置化的.
「Intl.ListFormat」 是 JavaScript 的國際化 API 之一,它用于格式化列表,以便在多語言環境中創建自然語言的列表表示。Intl.ListFormat 允許你指定列表項的連接方式(如逗號、"和" 等),以及列表項的樣式和語言設置。
const listFormatter=new Intl.ListFormat('en-US', { style: 'long', type: 'disjunction' });
const items=['apples', 'bananas', 'cherries'];
const formattedList=listFormatter.format(items);
console.log(formattedList); // 根據配置輸出例如:"apples, bananas, or cherries"
const listFormatter=new Intl.ListFormat('en-US', { style: 'short', type: 'conjunction' });
const items=['apples', 'bananas', 'cherries'];
const formattedList=listFormatter.format(items);
console.log(formattedList); // 根據配置輸出例如:"apples, bananas, & cherries"
const listFormatter=new Intl.ListFormat('en-US', { style: 'narrow', type: 'conjunction' });
const items=['apples', 'bananas', 'cherries'];
const formattedList=listFormatter.format(items);
console.log(formattedList); // 根據配置輸出例如:"apples, bananas, cherries"
可以發現 options可以傳遞參數style和type, 有如下取值
日歷是一種常見的東西, 在中國我們經常接觸到公歷和農歷,公歷全稱格里高利歷, 英文gregory。
現在國家節日很多都是跟隨農歷的,比如春節,中秋節等。以前家家人手一本農歷, 上面會今日宜做什么, 現在很少見但是老人家還是信這個。
而與此相同, 每個地方都有自己的歷法
const date=new Date(); // 當前日期, Mon Oct 30 2023 20:00:50 GMT+0800 (中國標準時間)
const formattedDate=new Intl.DateTimeFormat('ar-SA-u-ca-islamic', { year: 'numeric', month: 'long', day: 'numeric' }).format(date);
console.log(formattedDate); // ?? ???? ????? ???? ??
const date=new Date(1994, 1, 26);
const formattedDate=new Intl.DateTimeFormat('zh-CN-u-ca-chinese', { year: 'numeric', month: 'long', day: 'numeric' }).format(date);
console.log(formattedDate); // 1994甲戌年正月17
// 不同語言下不同日歷的名稱
const calendarNames=new Intl.DisplayNames('en-US', { type: 'calendar' });
console.log(calendarNames.of('gregory')); // 輸出:Gregorian
console.log(calendarNames.of('islamic')); // 輸出:Islamic
const calendarNames=new Intl.DisplayNames('zh-CN', { type: 'calendar' });
console.log(calendarNames.of('gregory')); // 輸出:公歷
console.log(calendarNames.of('islamic')); // 輸出:伊斯蘭歷
除了上述gregory格里高利歷, 取值還有下面這些
不得不說日本的和歷, 真的是很神奇. 不是中文的周一到周日, 也不是Sunday-Saturday. 首先日本還有皇帝, 有皇帝就有年號. 常見下面的一些年份
可以看到日本的日歷起始時周日(日), 但是周一到周六分別對應月火水木金土. 與眾不同
// 獲取今天過去7天的日期
// 日期對象
const today=new Date();
// 創建一個選項對象,指定輸出的語言和風格
const optionsCN={ weekday: 'long' };
const optionsJP={ weekday: 'long' };
// 獲取過去一周的日期
console.log('\n過去一周的日期:');
const CNArr=[]
const JPArr=[]
for (let i=0; i < 7; i++) {
const pastDate=new Date(today);
pastDate.setDate(today.getDate() - i);
CNArr.unshift(new Intl.DateTimeFormat('zh-CN', optionsCN).format(pastDate))
JPArr.unshift(new Intl.DateTimeFormat('ja-JP', optionsJP).format(pastDate))
}
console.log('CNArr', CNArr.join(' ')) // CNArr 星期二 星期三 星期四 星期五 星期六 星期日 星期一
console.log('JPArr', JPArr.join(' ')) // JPArr 火曜日 水曜日 木曜日 金曜日 土曜日 日曜日 月曜日
const dateTimeFields=new Intl.DisplayNames('en-US', { type: 'dateTimeField' });
console.log(dateTimeFields.of('era')); // 輸出:Era, 紀元的意思
console.log(dateTimeFields.of('year')); // 輸出:Year
console.log(dateTimeFields.of('month')); // 輸出:Month
console.log(dateTimeFields.of('day')); // 輸出:Day
console.log(dateTimeFields.of('weekday')); // 輸出:Day of the week
console.log(dateTimeFields.of('hour')); // 輸出:Hour
console.log(dateTimeFields.of('minute')); // 輸出:Minute
console.log(dateTimeFields.of('second')); // 輸出:Second
console.log(dateTimeFields.of('quarter')); // 輸出:Quarter
const dateTimeFields=new Intl.DisplayNames('ja-JP', { type: 'dateTimeField' });
console.log(dateTimeFields.of('era')); // 輸出:時代
console.log(dateTimeFields.of('year')); // 輸出:年
console.log(dateTimeFields.of('month')); // 輸出:月
console.log(dateTimeFields.of('day')); // 輸出:日
console.log(dateTimeFields.of('weekday')); // 輸出:曜日
console.log(dateTimeFields.of('hour')); // 輸出:時
console.log(dateTimeFields.of('minute')); // 輸出:分
console.log(dateTimeFields.of('second')); // 輸出:秒
console.log(dateTimeFields.of('quarter')); // 輸出:四半期
en-US一般每周第一天是周日, 而zh-CN一般每周第一天是周一. 可以通過如下信息判斷
Intl. Locale函數返回屬性里firstDay是一個數字,其中 0或7 表示星期日,1 表示星期一,依此類推。不同的地區和文化可能會將每周的第一天設置為不同的日期,因此這個屬性可以幫助你確定每周的起始日期,例如,星期天或星期一。
(new Intl.Locale('zh-CN')).weekInfo // {"firstDay":1,"weekend":[6,7],"minimalDays":1}
(new Intl.Locale('en-US')).weekInfo // {"firstDay":7,"weekend":[6,7],"minimalDays":1}
const locale='zh-CN'
console.log(new Intl.DateTimeFormat(locale, { weekday: "long" }).format(new Date(0, 0, new Intl.Locale(locale).weekInfo.firstDay))) // '星期一'
const locale='en-US'
console.log(new Intl.DateTimeFormat(locale, { weekday: "long" }).format(new Date(0, 0, new Intl.Locale(locale).weekInfo.firstDay))) // 'Sunday'
能生效的原因就是 new Date(0,0,7) 等價于 new Date(1900,0,7) 對應1900.1.7(UTC+0), 此時對應us的sunday. 而 new Date(0,0,1) 對應1900.1.1(UTC+0), 對應cn的星期一
Intl-region-language-calendar:https://codesandbox.io/p/devbox/intl-region-language-calendar-xm7ls8?embed=1&file=%2Fsrc%2FApp.tsx
const localeUS='en-US'
new Intl.DateTimeFormat(localeUS, { weekday: "long" }).format(new Date(0, 0, new Intl.Locale(localeUS).weekInfo.firstDay)) // 'Sunday'
const localeCN='zh-CN'
new Intl.DateTimeFormat(localeCN, { weekday: "long" }).format(new Date(0, 0, new Intl.Locale(localeCN).weekInfo.firstDay)) // '星期一'
const locale="en-US";
const firstDay=new Intl.Locale(locale).weekInfo.firstDay;
const formatInstace=new Intl.DateTimeFormat(locale, { weekday: "long" });
for (let i=0; i < 7; i++) {
console.log(formatInstace.format(new Date(0, 0, firstDay + i)));
}
// Sunday
// Monday
// Tuesday
// Wednesday
// Thursday
// Friday
// Saturday
const localeCN="zh-CN";
const firstDayCN=new Intl.Locale(localeCN).weekInfo.firstDay;
const formatInstaceCN=new Intl.DateTimeFormat(localeCN, { weekday: "long" });
for (let i=0; i < 7; i++) {
console.log(formatInstaceCN.format(new Date(0, 0, firstDayCN + i)));
}
// 星期一
// 星期二
// 星期三
// 星期四
// 星期五
// 星期六
// 星期日
可以通過API, Intl.supportedValuesOf 獲取到所有支持的
const calendars=Intl.supportedValuesOf("calendar");
console.log(calendars); // 輸出所有支持的日歷系統
// (18) ['buddhist', 'chinese', 'coptic', 'dangi', 'ethioaa', 'ethiopic', 'gregory', 'hebrew', 'indian', 'islamic', 'islamic-civil', 'islamic-rgsa', 'islamic-tbla', 'islamic-umalqura', 'iso8601', 'japanese', 'persian', 'roc']
const currencies=Intl.supportedValuesOf("currency");
console.log(currencies); // 輸出所有支持的貨幣代碼
// (159) ['AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYN', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CUC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'INR', 'IQD', 'IRR', 'ISK', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRU', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', …]
const timeZones=Intl.supportedValuesOf("timeZone");
console.log(timeZones); // 輸出所有支持的時區
// (428) ['Africa/Abidjan', 'Africa/Accra', 'Africa/Addis_Ababa', 'Africa/Algiers', 'Africa/Asmera', 'Africa/Bamako', 'Africa/Bangui', 'Africa/Banjul', 'Africa/Bissau', 'Africa/Blantyre', 'Africa/Brazzaville', 'Africa/Bujumbura', 'Africa/Cairo', 'Africa/Casablanca', 'Africa/Ceuta', 'Africa/Conakry', 'Africa/Dakar', 'Africa/Dar_es_Salaam', 'Africa/Djibouti', 'Africa/Douala', 'Africa/El_Aaiun', 'Africa/Freetown', 'Africa/Gaborone', 'Africa/Harare', 'Africa/Johannesburg', 'Africa/Juba', 'Africa/Kampala', 'Africa/Khartoum', 'Africa/Kigali', 'Africa/Kinshasa', 'Africa/Lagos', 'Africa/Libreville', 'Africa/Lome', 'Africa/Luanda', 'Africa/Lubumbashi', 'Africa/Lusaka', 'Africa/Malabo', 'Africa/Maputo', 'Africa/Maseru', 'Africa/Mbabane', 'Africa/Mogadishu', 'Africa/Monrovia', 'Africa/Nairobi', 'Africa/Ndjamena', 'Africa/Niamey', 'Africa/Nouakchott', 'Africa/Ouagadougou', 'Africa/Porto-Novo', 'Africa/Sao_Tome', 'Africa/Tripoli', 'Africa/Tunis', 'Africa/Windhoek', 'America/Adak', 'America/Anchorage', 'America/Anguilla', 'America/Antigua', 'America/Araguaina', 'America/Argentina/La_Rioja', 'America/Argentina/Rio_Gallegos', 'America/Argentina/Salta', 'America/Argentina/San_Juan', 'America/Argentina/San_Luis', 'America/Argentina/Tucuman', 'America/Argentina/Ushuaia', 'America/Aruba', 'America/Asuncion', 'America/Bahia', 'America/Bahia_Banderas', 'America/Barbados', 'America/Belem', 'America/Belize', 'America/Blanc-Sablon', 'America/Boa_Vista', 'America/Bogota', 'America/Boise', 'America/Buenos_Aires', 'America/Cambridge_Bay', 'America/Campo_Grande', 'America/Cancun', 'America/Caracas', 'America/Catamarca', 'America/Cayenne', 'America/Cayman', 'America/Chicago', 'America/Chihuahua', 'America/Ciudad_Juarez', 'America/Coral_Harbour', 'America/Cordoba', 'America/Costa_Rica', 'America/Creston', 'America/Cuiaba', 'America/Curacao', 'America/Danmarkshavn', 'America/Dawson', 'America/Dawson_Creek', 'America/Denver', 'America/Detroit', 'America/Dominica', 'America/Edmonton', 'America/Eirunepe', …]
Intl是瀏覽器對i18n提供的底層API, 用于處理國際化相關內容. 附帶browerstack 云真機測試工具(caniuse推薦): https://live.browserstack.com/dashboard如果沒處理好兼容性問題直接使用API, 會報JS Error. 內容為Intl.DisplayNames is not a constructor
對應的, 一些操作系統低版本的用戶(長期不升級系統)會遇到JS Error導致白屏
Google pixel4(2019, October 15發行)
可以用formatjs提供的polyfill做低版本兼容: https://formatjs.io/docs/getting-started/installation
async function loadPolyfill() {
// 如果當前環境不支持 Intl 或者 Intl.DisplayNames
if (!window.Intl || !window.Intl.DisplayNames) {
window.Intl=window.Intl || {}
// 加載 polyfill
await import('@formatjs/intl-getcanonicallocales/polyfill-force')
await import('@formatjs/intl-locale/polyfill-force')
await import('@formatjs/intl-displaynames/polyfill-force')
await import('@formatjs/intl-displaynames/locale-data/en')
return false
} else {
// 當前環境支持 Intl.DisplayNames API,不需要 Polyfill
return true
}
}
眾所周知Babel有一個babel-preset-env(https://www.babeljs.cn/docs/babel-preset-env#how-does-it-work), 用于在編譯代碼時智能(基于core-js-compat(https://www.npmjs.com/package/core-js-compat))引入helper和polyfill 智能的含義: 可以設置最低兼容的瀏覽器(https://github.com/browserslist/browserslist#queries)和代碼, 動態引用所需的helper和polyfill
// babel.config.js
module.exports={
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage', // 根據每個文件里面,用到了哪些es的新特性和targets導入polyfill,更加精簡
corejs: 3, // 指定 core-js 版本
targets: "> 0.25%, not dead" // 指定目標瀏覽器, 選取全球使用率超過 0.25% 的瀏覽器版本
},
],
],
};
「babel底層使用core-js(https://github.com/zloirock/core-js)進行polyfill, 但是core-js不包含Intl API部分的polyfill(https://github.com/zloirock/core-js?tab=readme-ov-file#missing-polyfills), 所以babel并不能為Intl API做polyfill」
npm i @formatjs/intl
import {createIntl, createIntlCache} from '@formatjs/intl'
// This is optional but highly recommended
// since it prevents memory leak
const cache=createIntlCache()
const intlFr=createIntl(
{
locale: 'fr-FR',
messages: {},
},
cache
)
const intlEn=createIntl(
{
locale: 'en-US',
message: {},
cache
}
)
// Call imperatively
console.log(intlFr.formatNumber(2000000000000)) // 2 000 000 000 000
console.log(intlEn.formatNumber(2000000000000)) // 2,000,000,000,000
作者:ManfredHu
來源-微信公眾號:字節前端 ByteFE
出處:https://mp.weixin.qq.com/s/PByp6Pmc3vp7b0acyPT8yA
節跳動面試官:請你實現一個大文件上傳和斷點續傳
原 作 者:yeyan1996
原文鏈接:https://url.cn/5h66afn
這段時間面試官都挺忙的,頻頻出現在博客文章標題,雖然我不是特別想蹭熱度,但是實在想不到好的標題了-。-,蹭蹭就蹭蹭 :)
事實上我在面試的時候確實被問到了這個問題,而且是一道在線 coding 的編程題,當時雖然思路正確,可惜最終也并不算完全答對。
結束后花了一段時間整理了下思路,那么究竟該如何實現一個大文件上傳,以及在上傳中如何實現斷點續傳的功能呢?
本文將從零搭建前端和服務端,實現一個大文件上傳和斷點續傳的 demo:
文章有誤解的地方,歡迎指出,將在第一時間改正,有更好的實現方式希望留下你的評論。
前端大文件上傳網上的大部分文章已經給出了解決方案,核心是利用 Blob.prototype.slice 方法,此方法和數組的 slice 方法相似,調用的 slice 方法可以返回原文件的某個切片。
這樣我們就可以根據預先設置好的切片最大數量將文件切分為一個個切片,然后借助 http 的可并發性,同時上傳多個切片,這樣從原本傳一個大文件,變成了同時傳多個小的文件切片,可以大大減少上傳時間。
另外由于是并發,傳輸到服務端的順序可能會發生變化,所以我們還需要給每個切片記錄順序。
服務端需要負責接受這些切片,并在接收到所有切片后合并切片。
這里又引伸出兩個問題:
第一個問題需要前端進行配合,前端在每個切片中都攜帶切片最大數量的信息,當服務端接收到這個數量的切片時自動合并,也可以額外發一個請求主動通知服務端進行切片的合并。
第二個問題,具體如何合并切片呢?這里可以使用 NodeJS 的 API fs.appendFileSync,它可以同步地將數據追加到指定文件,也就是說,當服務端接收完所有切片后,可以先創建一個空文件,然后將所有切片逐步合并到這個文件中。
so,talk is cheap, show me the code,接著讓我們用代碼實現上面的思路吧。
前端使用 Vue 作為開發框架,對界面沒有太大要求,原生也可以,考慮到美觀使用 Element-UI 作為 UI 框架。
首先創建選擇文件的控件,監聽 change 事件以及上傳按鈕:
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
export default {
data: ()=> ({
container: {
file: null
}
}),
methods: {
handleFileChange(e) {
const [file]=e.target.files;
if (!file) return;
Object.assign(this.$data, this.$options.data());
this.container.file=file;
},
async handleUpload() {}
}
};
</script>
考慮到通用性,這里沒有用第三方的請求庫,而是用原生 XMLHttpRequest 做一層簡單的封裝來發請求:
request({
url,
method="post",
data,
headers={},
requestList
}) {
return new Promise(resolve=> {
const xhr=new XMLHttpRequest();
xhr.open(method, url);
Object.keys(headers).forEach(key=>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload=e=> {
resolve({
data: e.target.response
});
};
});
}
接著實現比較重要的上傳功能,上傳需要做兩件事:
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
+ const LENGTH=10; // 切片數量
export default {
data: ()=> ({
container: {
file: null,
+ data: []
}
}),
methods: {
request() {},
handleFileChange() {},
+ // 生成文件切片
+ createFileChunk(file, length=LENGTH) {
+ const fileChunkList=[];
+ const chunkSize=Math.ceil(file.size / length);
+ let cur=0;
+ while (cur < file.size) {
+ fileChunkList.push({ file: file.slice(cur, cur + chunkSize) });
+ cur +=chunkSize;
+ }
+ return fileChunkList;
+ },
+ // 上傳切片
+ async uploadChunks() {
+ const requestList=this.data
+ .map(({ chunk })=> {
+ const formData=new FormData();
+ formData.append("chunk", chunk);
+ formData.append("hash", hash);
+ formData.append("filename", this.container.file.name);
+ return { formData };
+ })
+ .map(async ({ formData })=>
+ this.request({
+ url: "http://localhost:3000",
+ data: formData
+ })
+ );
+ await Promise.all(requestList); // 并發切片
+ },
+ async handleUpload() {
+ if (!this.container.file) return;
+ const fileChunkList=this.createFileChunk(this.container.file);
+ this.data=fileChunkList.map(({ file },index)=> ({
+ chunk: file,
+ hash: this.container.file.name + "-" + index // 文件名 + 數組下標
+ }));
+ await this.uploadChunks();
+ }
}
};
</script>
當點擊上傳按鈕時,調用 createFileChunk 將文件切片,切片數量通過一個常量 Length 控制,這里設置為 10,即將文件分成 10 個切片上傳。
createFileChunk 內使用 while 循環和 slice 方法將切片放入 fileChunkList 數組中返回。
在生成文件切片時,需要給每個切片一個標識作為 hash,這里暫時使用文件名 + 下標,這樣后端可以知道當前切片是第幾個切片,用于之后的合并切片。
隨后調用 uploadChunks 上傳所有的文件切片,將文件切片,切片 hash,以及文件名放入 FormData 中,再調用上一步的 request 函數返回一個 proimise,最后調用 Promise.all 并發上傳所有的切片。
這里使用整體思路中提到的第二種合并切片的方式,即前端主動通知服務端進行合并,所以前端還需要額外發請求,服務端接受到這個請求時主動合并切片
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
export default {
data: ()=> ({
container: {
file: null
},
data: []
}),
methods: {
request() {},
handleFileChange() {},
createFileChunk() {},
// 上傳切片,同時過濾已上傳的切片
async uploadChunks() {
const requestList=this.data
.map(({ chunk })=> {
const formData=new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
return { formData };
})
.map(async ({ formData })=>
this.request({
url: "http://localhost:3000",
data: formData
})
);
await Promise.all(requestList);
+ // 合并切片
+ await this.mergeRequest();
},
+ async mergeRequest() {
+ await this.request({
+ url: "http://localhost:3000/merge",
+ headers: {
+ "content-type": "application/json"
+ },
+ data: JSON.stringify({
+ filename: this.container.file.name
+ })
+ });
+ },
async handleUpload() {}
}
};
</script>
簡單使用 HTTP 模塊搭建服務端:
const http=require("http");
const server=http.createServer();
server.on("request", async (req, res)=> {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method==="OPTIONS") {
res.status=200;
res.end();
return;
}
});
server.listen(3000, ()=> console.log("正在監聽 3000 端口"));
使用 multiparty 包處理前端傳來的 FormData,在 multiparty.parse 的回調中,files 參數保存了 FormData 中文件,fields 參數保存了 FormData 中非文件的字段:
const http=require("http");
const path=require("path");
const fse=require("fs-extra");
const multiparty=require("multiparty");
const server=http.createServer();
+ const UPLOAD_DIR=path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
server.on("request", async (req, res)=> {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method==="OPTIONS") {
res.status=200;
res.end();
return;
}
+ const multipart=new multiparty.Form();
+ multipart.parse(req, async (err, fields, files)=> {
+ if (err) {
+ return;
+ }
+ const [chunk]=files.chunk;
+ const [hash]=fields.hash;
+ const [filename]=fields.filename;
+ const chunkDir=`${UPLOAD_DIR}/${filename}`;
+ // 切片目錄不存在,創建切片目錄
+ if (!fse.existsSync(chunkDir)) {
+ await fse.mkdirs(chunkDir);
+ }
+ // fs-extra 專用方法,類似 fs.rename 并且跨平臺
+ // fs-extra 的 rename 方法 windows 平臺會有權限問題
+ // https://github.com/meteor/meteor/issues/7852#issuecomment-255767835
+ await fse.move(chunk.path, `${chunkDir}/${hash}`);
+ res.end("received file chunk");
+ });
});
server.listen(3000, ()=> console.log("正在監聽 3000 端口"));
查看 multiparty 處理后的 chunk 對象,path 是存儲臨時文件的路徑,size 是臨時文件大小,在 multiparty 文檔中提到可以使用 fs.rename(由于我用的是 fs-extra,其 rename 方法在 Windows 系統上存在權限問題,所以換成了 fse.move) 重命名的方式移動臨時文件,也就是文件切片。
在接受文件切片時,需要先創建存儲切片的文件夾,由于前端在發送每個切片時額外攜帶了唯一值 hash,所以以 hash 作為文件名,將切片從臨時路徑移動切片文件夾中,最后的結果如下
在接收到前端發送的合并請求后,服務端將文件夾下的所有切片進行合并
const http=require("http");
const path=require("path");
const fse=require("fs-extra");
const server=http.createServer();
const UPLOAD_DIR=path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
+ const resolvePost=req=>
+ new Promise(resolve=> {
+ let chunk="";
+ req.on("data", data=> {
+ chunk +=data;
+ });
+ req.on("end", ()=> {
+ resolve(JSON.parse(chunk));
+ });
+ });
+ // 合并切片
+ const mergeFileChunk=async (filePath, filename)=> {
+ const chunkDir=`${UPLOAD_DIR}/${filename}`;
+ const chunkPaths=await fse.readdir(chunkDir);
+ await fse.writeFile(filePath, "");
+ chunkPaths.forEach(chunkPath=> {
+ fse.appendFileSync(filePath, fse.readFileSync(`${chunkDir}/${chunkPath}`));
+ fse.unlinkSync(`${chunkDir}/${chunkPath}`);
+ });
+ fse.rmdirSync(chunkDir); // 合并后刪除保存切片的目錄
+ };
server.on("request", async (req, res)=> {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Headers", "*");
if (req.method==="OPTIONS") {
res.status=200;
res.end();
return;
}
+ if (req.url==="/merge") {
+ const data=await resolvePost(req);
+ const { filename }=data;
+ const filePath=`${UPLOAD_DIR}/${filename}`;
+ await mergeFileChunk(filePath, filename);
+ res.end(
+ JSON.stringify({
+ code: 0,
+ message: "file merged success"
+ })
+ );
+ }
});
server.listen(3000, ()=> console.log("正在監聽 3000 端口"));
由于前端在發送合并請求時會攜帶文件名,服務端根據文件名可以找到上一步創建的切片文件夾。
接著使用 fs.writeFileSync 先創建一個空文件,這個空文件的文件名就是切片文件夾名 + 后綴名組合而成,隨后通過 fs.appendFileSync 從切片文件夾中不斷將切片合并到空文件中,每次合并完成后刪除這個切片,等所有切片都合并完畢后最后刪除切片文件夾。
至此一個簡單的大文件上傳就完成了,接下來我們再此基礎上擴展一些額外的功能。
上傳進度分兩種,一個是每個切片的上傳進度,另一個是整個文件的上傳進度,而整個文件的上傳進度是基于每個切片上傳進度計算而來,所以我們需要先實現切片的上傳進度。
XMLHttpRequest 原生支持上傳進度的監聽,只需要監聽 upload.onprogress 即可,我們在原來的 request 基礎上傳入 onProgress 參數,給 XMLHttpRequest 注冊監聽事件:
// xhr
request({
url,
method="post",
data,
headers={},
+ onProgress=e=> e,
requestList
}) {
return new Promise(resolve=> {
const xhr=new XMLHttpRequest();
+ xhr.upload.onprogress=onProgress;
xhr.open(method, url);
Object.keys(headers).forEach(key=>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload=e=> {
resolve({
data: e.target.response
});
};
});
}
由于每個切片都需要觸發獨立的監聽事件,所以還需要一個工廠函數,根據傳入的切片返回不同的監聽函數。
在原先的前端上傳邏輯中新增監聽函數部分:
// 上傳切片,同時過濾已上傳的切片
async uploadChunks(uploadedList=[]) {
const requestList=this.data
.map(({ chunk })=> {
const formData=new FormData();
formData.append("chunk", chunk);
formData.append("filename", this.container.file.name);
return { formData };
})
.map(async ({ formData })=>
this.request({
url: "http://localhost:3000",
data: formData,
+ onProgress: this.createProgressHandler(this.data[index]),
})
);
await Promise.all(requestList);
// 合并切片
await this.mergeRequest();
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList=this.createFileChunk(this.container.file);
this.data=fileChunkList.map(({ file },index)=> ({
chunk: file,
+ index,
hash: this.container.file.name + "-" + index
+ percentage:0
}));
await this.uploadChunks();
}
+ createProgressHandler(item) {
+ return e=> {
+ item.percentage=parseInt(String((e.loaded / e.total) * 100));
+ };
+ }
每個切片在上傳時都會通過監聽函數更新 data 數組對應元素的 percentage 屬性,之后把將 data 數組放到視圖中展示即可。
將每個切片已上傳的部分累加,除以整個文件的大小,就能得出當前文件的上傳進度,所以這里使用 Vue 計算屬性:
computed: {
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
const loaded=this.data
.map(item=> item.size * item.percentage)
.reduce((acc, cur)=> acc + cur);
return parseInt((loaded / this.container.file.size).toFixed(2));
}
}
最終效果如下:
斷點續傳的原理在于前端/服務端需要記住已上傳的切片,這樣下次上傳就可以跳過之前已上傳的部分,有兩種方案實現記憶的功能:
第一種是前端的解決方案,第二種是服務端,而前端方案有一個缺陷,如果換了個瀏覽器就失去了記憶的效果,所以這里選取后者。
無論是前端還是服務端,都必須要生成文件和切片的 hash,之前我們使用文件名 + 切片下標作為切片 hash,這樣做文件名一旦修改就失去了效果,而事實上只要文件內容不變,hash 就不應該變化,所以正確的做法是根據文件內容生成 hash,所以我們需要修改一下 hash 的生成規則。
這里用到另一個庫 spark-md5,它可以根據文件內容計算出文件的 hash 值,另外考慮到如果上傳一個超大文件,讀取文件內容計算 hash 是非常耗費時間的,并且會引起 UI 的阻塞,導致頁面假死狀態,所以我們使用 web-worker 在 worker 線程計算 hash,這樣用戶仍可以在主界面正常的交互。
由于實例化 web-worker 時,參數是一個 JavaScript 文件路徑,且不能跨域。所以我們單獨創建一個 hash.js 文件放在 public 目錄下,另外在 worker 中也是不允許訪問 DOM 的,但它提供了importScripts 函數用于導入外部腳本,通過它導入 spark-md5。
// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 導入腳本
// 生成文件 hash
self.onmessage=e=> {
const { fileChunkList }=e.data;
const spark=new self.SparkMD5.ArrayBuffer();
let percentage=0;
let count=0;
const loadNext=index=> {
const reader=new FileReader();
reader.readAsArrayBuffer(fileChunkList[index].file);
reader.onload=e=> {
count++;
spark.append(e.target.result);
if (count===fileChunkList.length) {
self.postMessage({
percentage: 100,
hash: spark.end()
});
self.close();
} else {
percentage +=100 / fileChunkList.length;
self.postMessage({
percentage
});
// 遞歸計算下一個切片
loadNext(count);
}
};
};
loadNext(0);
};
在 worker 線程中,接受文件切片 fileChunkList,利用 FileReader 讀取每個切片的 ArrayBuffer 并不斷傳入 spark-md5 中,每計算完一個切片通過 postMessage 向主線程發送一個進度事件,全部完成后將最終的 hash 發送給主線程。
spark-md5 需要根據所有切片才能算出一個 hash 值,不能直接將整個文件放入計算,否則即使不同文件也會有相同的 hash,具體可以看官方文檔。
spark-md5[1]
接著編寫主線程與 worker 線程通訊的邏輯
+ // 生成文件 hash(web-worker)
+ calculateHash(fileChunkList) {
+ return new Promise(resolve=> {
+ // 添加 worker 屬性
+ this.container.worker=new Worker("/hash.js");
+ this.container.worker.postMessage({ fileChunkList });
+ this.container.worker.onmessage=e=> {
+ const { percentage, hash }=e.data;
+ this.hashPercentage=percentage;
+ if (hash) {
+ resolve(hash);
+ }
+ };
+ });
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList=this.createFileChunk(this.container.file);
+ this.container.hash=await this.calculateHash(fileChunkList);
this.data=fileChunkList.map(({ file },index)=> ({
+ fileHash: this.container.hash,
chunk: file,
hash: this.container.file.name + "-" + index, // 文件名 + 數組下標
percentage:0
}));
await this.uploadChunks();
}
主線程使用 postMessage 給 worker 線程傳入所有切片 fileChunkList,并監聽 worker 線程發出的 postMessage 事件拿到文件 hash。
加上顯示計算 hash 的進度條,看起來像這樣
至此前端需要將之前用文件名作為 hash 的地方改寫為 workder 返回的這個 hash。
服務端則使用 hash 作為切片文件夾名,hash + 下標作為切片名,hash + 擴展名作為文件名,沒有新增的邏輯。
在實現斷點續傳前先簡單介紹一下文件秒傳。
所謂的文件秒傳,即在服務端已經存在了上傳的資源,所以當用戶再次上傳時會直接提示上傳成功
文件秒傳需要依賴上一步生成的 hash,即在上傳前,先計算出文件 hash,并把 hash 發送給服務端進行驗證,由于 hash 的唯一性,所以一旦服務端能找到 hash 相同的文件,則直接返回上傳成功的信息即可。
+ async verifyUpload(filename, fileHash) {
+ const { data }=await this.request({
+ url: "http://localhost:3000/verify",
+ headers: {
+ "content-type": "application/json"
+ },
+ data: JSON.stringify({
+ filename,
+ fileHash
+ })
+ });
+ return JSON.parse(data);
+ },
async handleUpload() {
if (!this.container.file) return;
const fileChunkList=this.createFileChunk(this.container.file);
this.container.hash=await this.calculateHash(fileChunkList);
+ const { shouldUpload }=await this.verifyUpload(
+ this.container.file.name,
+ this.container.hash
+ );
+ if (!shouldUpload) {
+ this.$message.success("秒傳:上傳成功");
+ return;
+ }
this.data=fileChunkList.map(({ file }, index)=> ({
fileHash: this.container.hash,
index,
hash: this.container.hash + "-" + index,
chunk: file,
percentage: 0
}));
await this.uploadChunks();
}
秒傳其實就是給用戶看的障眼法,實質上根本沒有上傳。就像下面這行代碼 :)
服務端的邏輯非常簡單,新增一個驗證接口,驗證文件是否存在即可。
+ const extractExt=filename=>
+ filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
const UPLOAD_DIR=path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
const resolvePost=req=>
new Promise(resolve=> {
let chunk="";
req.on("data", data=> {
chunk +=data;
});
req.on("end", ()=> {
resolve(JSON.parse(chunk));
});
});
server.on("request", async (req, res)=> {
if (req.url==="/verify") {
+ const data=await resolvePost(req);
+ const { fileHash, filename }=data;
+ const ext=extractExt(filename);
+ const filePath=`${UPLOAD_DIR}/${fileHash}${ext}`;
+ if (fse.existsSync(filePath)) {
+ res.end(
+ JSON.stringify({
+ shouldUpload: false
+ })
+ );
+ } else {
+ res.end(
+ JSON.stringify({
+ shouldUpload: true
+ })
+ );
+ }
}
});
server.listen(3000, ()=> console.log("正在監聽 3000 端口"));
講完了生成 hash 和文件秒傳,回到斷點續傳。
斷點續傳顧名思義即斷點 + 續傳,所以我們第一步先實現"斷點",也就是暫停上傳。
原理是使用 XMLHttpRequest 的 abort 方法,可以取消一個 xhr 請求的發送,為此我們需要將上傳每個切片的 xhr 對象保存起來,我們再改造一下 request 方法。
request({
url,
method="post",
data,
headers={},
onProgress=e=> e,
+ requestList
}) {
return new Promise(resolve=> {
const xhr=new XMLHttpRequest();
xhr.upload.onprogress=onProgress;
xhr.open(method, url);
Object.keys(headers).forEach(key=>
xhr.setRequestHeader(key, headers[key])
);
xhr.send(data);
xhr.onload=e=> {
+ // 將請求成功的 xhr 從列表中刪除
+ if (requestList) {
+ const xhrIndex=requestList.findIndex(item=> item===xhr);
+ requestList.splice(xhrIndex, 1);
+ }
resolve({
data: e.target.response
});
};
+ // 暴露當前 xhr 給外部
+ requestList?.push(xhr);
});
},
這樣在上傳切片時傳入 requestList 數組作為參數,request 方法就會將所有的 xhr 保存在數組中了。
每當一個切片上傳成功時,將對應的 xhr 從 requestList 中刪除,所以 requestList 中只保存正在上傳切片的 xhr。
之后新建一個暫停按鈕,當點擊按鈕時,調用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上傳的切片。
handlePause() {
this.requestList.forEach(xhr=> xhr?.abort());
this.requestList=[];
}
點擊暫停按鈕可以看到 xhr 都被取消了。
之前在介紹斷點續傳的時提到使用第二種服務端存儲的方式實現續傳
由于當文件切片上傳后,服務端會建立一個文件夾存儲所有上傳的切片,所以每次前端上傳前可以調用一個接口,服務端將已上傳的切片的切片名返回,前端再跳過這些已經上傳切片,這樣就實現了"續傳"的效果
而這個接口可以和之前秒傳的驗證接口合并,前端每次上傳前發送一個驗證的請求,返回兩種結果:
所以我們改造一下之前文件秒傳的服務端驗證接口:
const extractExt=filename=>
filename.slice(filename.lastIndexOf("."), filename.length); // 提取后綴名
const UPLOAD_DIR=path.resolve(__dirname, "..", "target"); // 大文件存儲目錄
const resolvePost=req=>
new Promise(resolve=> {
let chunk="";
req.on("data", data=> {
chunk +=data;
});
req.on("end", ()=> {
resolve(JSON.parse(chunk));
});
});
+ // 返回已經上傳切片名列表
+ const createUploadedList=async fileHash=>
+ fse.existsSync(`${UPLOAD_DIR}/${fileHash}`)
+ ? await fse.readdir(`${UPLOAD_DIR}/${fileHash}`)
+ : [];
server.on("request", async (req, res)=> {
if (req.url==="/verify") {
const data=await resolvePost(req);
const { fileHash, filename }=data;
const ext=extractExt(filename);
const filePath=`${UPLOAD_DIR}/${fileHash}${ext}`;
if (fse.existsSync(filePath)) {
res.end(
JSON.stringify({
shouldUpload: false
})
);
} else {
res.end(
JSON.stringify({
shouldUpload: true,
+ uploadedList: await createUploadedList(fileHash)
})
);
}
}
});
server.listen(3000, ()=> console.log("正在監聽 3000 端口"));
接著回到前端,前端有兩個地方需要調用驗證的接口:
新增恢復按鈕并改造原來上傳切片的邏輯:
<template>
<div id="app">
<input
type="file"
@change="handleFileChange"
/>
<el-button @click="handleUpload">上傳</el-button>
<el-button @click="handlePause" v-if="isPaused">暫停</el-button>
+ <el-button @click="handleResume" v-else>恢復</el-button>
//...
</div>
</template>
+ async handleResume() {
+ const { uploadedList }=await this.verifyUpload(
+ this.container.file.name,
+ this.container.hash
+ );
+ await this.uploadChunks(uploadedList);
},
async handleUpload() {
if (!this.container.file) return;
const fileChunkList=this.createFileChunk(this.container.file);
this.container.hash=await this.calculateHash(fileChunkList);
+ const { shouldUpload, uploadedList }=await this.verifyUpload(
this.container.file.name,
this.container.hash
);
if (!shouldUpload) {
this.$message.success("秒傳:上傳成功");
return;
}
this.data=fileChunkList.map(({ file }, index)=> ({
fileHash: this.container.hash,
index,
hash: this.container.hash + "-" + index,
chunk: file,
percentage: 0
}));
+ await this.uploadChunks(uploadedList);
},
// 上傳切片,同時過濾已上傳的切片
+ async uploadChunks(uploadedList=[]) {
const requestList=this.data
+ .filter(({ hash })=> !uploadedList.includes(hash))
.map(({ chunk, hash, index })=> {
const formData=new FormData();
formData.append("chunk", chunk);
formData.append("hash", hash);
formData.append("filename", this.container.file.name);
formData.append("fileHash", this.container.hash);
return { formData, index };
})
.map(async ({ formData, index })=>
this.request({
url: "http://localhost:3000",
data: formData,
onProgress: this.createProgressHandler(this.data[index]),
requestList: this.requestList
})
);
await Promise.all(requestList);
// 之前上傳的切片數量 + 本次上傳的切片數量=所有切片數量時
// 合并切片
+ if (uploadedList.length + requestList.length===this.data.length) {
await this.mergeRequest();
+ }
}
這里給原來上傳切片的函數新增 uploadedList 參數,即上圖中服務端返回的切片名列表,通過 filter 過濾掉已上傳的切片,并且由于新增了已上傳的部分,所以之前合并接口的觸發條件做了一些改動。
到這里斷點續傳的功能基本完成了。
雖然實現了斷點續傳,但還需要修改一下進度條的顯示規則,否則在暫停上傳/接收到已上傳切片時的進度條會出現偏差。
由于在點擊上傳/恢復上傳時,會調用驗證接口返回已上傳的切片,所以需要將已上傳切片的進度變成 100%。
async handleUpload() {
if (!this.container.file) return;
const fileChunkList=this.createFileChunk(this.container.file);
this.container.hash=await this.calculateHash(fileChunkList);
const { shouldUpload, uploadedList }=await this.verifyUpload(
this.container.file.name,
this.container.hash
);
if (!shouldUpload) {
this.$message.success("秒傳:上傳成功");
return;
}
this.data=fileChunkList.map(({ file }, index)=> ({
fileHash: this.container.hash,
index,
hash: this.container.hash + "-" + index,
chunk: file,
+ percentage: uploadedList.includes(index) ? 100 : 0
}));
await this.uploadChunks(uploadedList);
},
uploadedList 會返回已上傳的切片,在遍歷所有切片時判斷當前切片是否在已上傳列表里即可。
之前說到文件進度條是一個計算屬性,根據所有切片的上傳進度計算而來,這就遇到了一個問題:
點擊暫停會取消并清空切片的 xhr 請求,此時如果已經上傳了一部分,就會發現文件進度條有倒退的現象:
當點擊恢復時,由于重新創建了 xhr 導致切片進度清零,所以總進度條就會倒退。
解決方案是創建一個"假"的進度條,這個假進度條基于文件進度條,但只會停止和增加,然后給用戶展示這個假的進度條
這里我們使用 Vue 的監聽屬性:
data: ()=> ({
+ fakeUploadPercentage: 0
}),
computed: {
uploadPercentage() {
if (!this.container.file || !this.data.length) return 0;
const loaded=this.data
.map(item=> item.size * item.percentage)
.reduce((acc, cur)=> acc + cur);
return parseInt((loaded / this.container.file.size).toFixed(2));
}
},
watch: {
+ uploadPercentage(now) {
+ if (now > this.fakeUploadPercentage) {
+ this.fakeUploadPercentage=now;
+ }
}
},
當 uploadPercentage 即真的文件進度條增加時,fakeUploadPercentage 也增加,一旦文件進度條后退,假的進度條只需停止即可。
至此一個大文件上傳 + 斷點續傳的解決方案就完成了
大文件上傳:
斷點續傳:
源代碼增加了一些按鈕的狀態,交互更加友好,文章表達比較晦澀的地方可以跳轉到源代碼查看
file-upload[2]
大家好,這里是 FEHub,每天早上 9 點更新,為你嚴選優質文章,與你一起進步。
如果喜歡這篇文章,記得點贊,轉發。讓你的好基友和你一樣優秀。
歡迎關注 「FEHub」,每天進步一點點
[1]
spark-md5: https://www.npmjs.com/package/spark-md5
[2]
file-upload: https://github.com/yeyan1996/file-upload
[3]
寫給新手前端的各種文件上傳攻略,從小圖片到大文件斷點續傳: https://juejin.im/post/5da14778f265da5bb628e590
[4]
Blob.slice: https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice
言
本文較長(5000字左右),建議閱讀時間: 20min+
一個iOS App的穩定性,主要決定于整體的系統架構設計,同時也不可忽略編程的細節,正所謂“千里之堤,潰于蟻穴”,一旦考慮不周,看似無關緊要的代碼片段可能會帶來整體軟件系統的崩潰。尤其因為蘋果限制了熱更新機制,App本身的穩定性及容錯性就顯的更加重要,之前可以通過發布熱補丁的方式解決線上代碼問題,現在就需要在提交之前對App開發周期內的各個指標進行實時監測,盡量讓問題暴漏在開發階段,然后及時修復,減少線上出問題的幾率。針對一個App的開發周期,它的穩定性指標主要有以下幾個環節構成,用一個腦圖表示如下:
穩定性指標
1 開發過程
開發過程中,主要是通過監控內存使用及泄露,CPU使用率,FPS,啟動時間等指標,以及常見的UI的主線程監測,NSAssert斷言等,最好能在Debug模式下,實時顯示在界面上,針對出現的問題及早解決。
內存問題
內存問題主要包括兩個部分,一個是iOS中常見循環引用導致的內存泄露 ,另外就是大量數據加載及使用導致的內存警告。
mmap
雖然蘋果并沒有明確每個App在運行期間可以使用的內存最大值,但是有開發者進行了實驗和統計,一般在占用系統內存超過20%的時候會有內存警告,而超過50%的時候,就很容易Crash了,所以內存使用率還是盡量要少,對于數據量比較大的應用,可以采用分步加載數據的方式,或者采用mmap方式。mmap 是使用邏輯內存對磁盤文件進行映射,中間只是進行映射沒有任何拷貝操作,避免了寫文件的數據拷貝。 操作內存就相當于在操作文件,避免了內核空間和用戶空間的頻繁切換。之前在開發輸入法的時候 ,詞庫的加載也是使用mmap方式,可以有效降低App的內存占用率,具體使用可以參考鏈接第一篇文章。
循環引用
循環引用是iOS開發中經常遇到的問題,尤其對于新手來說是個頭疼的問題。循環引用對App有潛在的危害,會使內存消耗過高,性能變差和Crash等,iOS常見的內存主要以下三種情況:
Delegate
代理協議是一個最典型的場景,需要你使用弱引用來避免循環引用。ARC時代,需要將代理聲明為weak是一個即好又安全的做法:
@property (nonatomic, weak) id delegate;
NSTimer
NSTimer我們開發中會用到很多,比如下面一段代碼
- (void)viewDidLoad {
這是典型的循環引用,因為timer會強引用self,而self又持有了timer,所有就造成了循環引用。那有人可能會說,我使用一個weak指針,比如
__weak typeof(self) weakSelf=self;
self.mytimer=[NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(doSomeThing) userInfo:nil repeats:YES];
但是其實并沒有用,因為不管是weakSelf還是strongSelf,最終在NSTimer內部都會重新生成一個新的指針指向self,這是一個強引用的指針,結果就會導致循環引用。那怎么解決呢?主要有如下三種方式:
使用類方法
使用weakProxy
使用GCD timer
具體如何使用,我就不做具體的介紹,網上有很多可以參考。
Block
Block的循環引用,主要是發生在ViewController中持有了block,比如:
@property (nonatomic, copy) LFCallbackBlock callbackBlock;
同時在對callbackBlock進行賦值的時候又調用了ViewController的方法,比如:
self.callbackBlock=^{
就會發生循環引用,因為:ViewController->強引用了callback->強引用了ViewController,解決方法也很簡單:
__weak __typeof(self) weakSelf=self;
原因是使用MRC管理內存時,Block的內存管理需要區分是Global(全局)、Stack(棧)還是Heap(堆),而在使用了ARC之后,蘋果自動會將所有原本應該放在棧中的Block全部放到堆中。全局的Block比較簡單,凡是沒有引用到Block作用域外面的參數的Block都會放到全局內存塊中,在全局內存塊的Block不用考慮內存管理問題。(放在全局內存塊是為了在之后再次調用該Block時能快速反應,當然沒有調用外部參數的Block根本不會出現內存管理問題)。
所以Block的內存管理出現問題的,絕大部分都是在堆內存中的Block出現了問題。默認情況下,Block初始化都是在棧上的,但可能隨時被收回,通過將Block類型聲明為copy類型,這樣對Block賦值的時候,會進行copy操作,copy到堆上,如果里面有對self的引用,則會有一個強引用的指針指向self,就會發生循環引用,如果采用weakSelf,內部不會有強類型的指針,所以可以解決循環引用問題。
那是不是所有的block都會發生循環引用呢?其實不然,比如UIView的類方法Block動畫,NSArray等的類的遍歷方法,也都不會發生循環引用,因為當前控制器一般不會強引用一個類。
其他內存問題
1 NSNotification addObserver之后,記得在dealloc里面添加remove;
2 動畫的repeat count無限大,而且也不主動停止動畫,基本就等于無限循環了;
3 forwardingTargetForSelector返回了self。
內存解決思路:
1 通過Instruments來查看leaks
2 集成Facebook開源的FBRetainCycleDetector
3 集成MLeaksFinder
具體原理及使用,可以參考鏈接。
CPU使用率
CPU的使用也可以通過兩種方式來查看,一種是在調試的時候Xcode會有展示,具體詳細信息可以進入Instruments內查看,通過查看Instruments的time profile來定位并解決問題。另一種常見的方法是通過代碼讀取CPU使用率,然后顯示在App的調試面板上,可以在Debug環境下顯示信息,具體代碼如下:
int result;
FPS監控
目前主要使用CADisplayLink來監控FPS,CADisplayLink是一個能讓我們以和屏幕刷新率相同的頻率將內容畫到屏幕上的定時器。我們在應用中創建一個新的 CADisplayLink 對象,把它添加到一個runloop中,并給它提供一個 target 和selector 在屏幕刷新的時候調用,需要注意的是添加到runloop的common mode里面,代碼如下:
- (void)setupDisplayLink {
啟動時間
點評App里面本身就包含了很多復雜的業務,比如外賣、團購、到綜和酒店等,同時還引入了很多第三方SDK比如微信、QQ、微博等,在App初始化的時候,很多SDK及業務也開始初始化,這就會拖慢應用的啟動時間。
App的啟動時間t(App總啟動時間)=t1(main()之前的加載時間) + t2(main()之后的加載時間)。
針對t1的優化,優化主要有如下:
減少不必要的framework,因為動態鏈接比較耗時;
檢查framework應當設為optional和required,如果該framework在當前App支持的所有iOS系統版本都存在,那么就設為required,否則就設為optional,因為optional會有些額外的檢查;
合并或者刪減一些OC類,這些我會在后續的靜態檢查中進行詳解;
針對t2的時間優化,可以采用:
異步初始化部分操作,比如網絡,數據讀取;
采用延遲加載或者懶加載某些視圖,圖片等的初始化操作;
對與圖片展示類的App,可以將解碼的圖片保存到本地,下次啟動時直接加載解碼后的圖片;
對實現了+load()方法的類進行分析,盡量將load里的代碼延后調用。
UI的主線程監測
我們都知道iOS的UI的操作一定是在主線程進行,該監測可以通過hook UIView的如下三個方法
-setNeedsLayout,
確保它們都是在主線程執行。子線程操作UI可能會引起什么問題,蘋果說得并不清楚,但是在實際開發中,我們經常會遇到整個App的動畫丟失,很大原因就是UI操作不是在主線程導致。
2 靜態分析過程
靜態分析在這里,我主要介紹兩方面,一個是正常的code review機制,另外一個就是代碼靜態檢查工具
code review
組內的code review機制,可以參考團隊之前的OpenDoc - 前端團隊CodeReview制度,iOS客戶端開發,會在此基礎上進行一些常見手誤及Crash情況的重點標記,比如:
1 我們開發中首先都是在測試環境開發,開發時可以將測試環境的url寫死到代碼中,但是在提交代碼的時候一定要將他改為線上環境的url,這個就可以通過gitlab中的重點比較部分字符串,給提交者一個強力的提示;
2 其他常見Crash的重點檢查,比如NSMutableString/NSMutableArray/NSMutableDictionary/NSMutableSet 等類下標越界判斷保護,或者 append/insert/add nil對象的保護;
3 ARC下的release操作,UITableViewCell返回nil,以及前面介紹的常見的循環引用等。
code review機制,一方面是依賴寫代碼者的代碼習慣及質量,另一名依賴審查者的經驗和細心程度,即使讓多人revew,也可能會漏過一些錯誤,所以我們又添加了代碼的靜態檢查。
代碼靜態檢查
代碼靜態分析(Static Program Analysis)是指在不運行程序的條件下,由代碼靜態分析工具自動對程序進行分析的方法. iOS常見的靜態掃描工具有Clang Static Analyzer、OCLint、Infer,這些主要是用來檢查可能存在的問題,還有Deploymate用來檢查api的兼容性。
Clang Static Analyzer
Clang Static Analyzer是一款靜態代碼掃描工具,專門用于針對C,C++和Objective-C的程序進行分析。已經被Xcode集成,可以直接使用Xcode進行靜態代碼掃描分析,Clang默認的配置主要是空指針檢測,類型轉換檢測,空判斷檢測,內存泄漏檢測這種等問題。如果需要更多的配置,可以使用開源的Clang項目,然后集成到自己的CI上。
OCLint
OCLint是一個強大的靜態代碼分析工具,可以用來提高代碼質量,查找潛在的bug,主要針對 C、C++和Objective-C的靜態分析。功能非常強大,而且是出自國人之手。OCLint基于 Clang 輸出的抽象語法樹對代碼進行靜態分析,支持與現有的CI集成,部署之后基本不需要維護,簡單方便。
OCLint可以發現這些問題
可能的bug - 空的 if / else / try / catch / finally 語句
未使用的代碼 - 未使用的局部變量和參數
復雜的代碼 - 高圈復雜度, NPath復雜, 高NCSS
冗余代碼 - 多余的if語句和無用的括號
壞味道的代碼 - 過長的方法和過長的參數列表
不好的使用 - 倒邏輯和入參重新賦值
對于OCLint的與原理和部署方法,可以參考團隊成員之前的文章:靜態代碼分析之OCLint的那些事兒,每次提交代碼后,可以在打包的過程中進行代碼檢查,及早發現有問題的代碼。當然也可以在合并代碼之前執行對應的檢查,如果檢查不通過,不能合并代碼,這樣檢查的力度更大。
Infer
Infer facebook開源的靜態分析工具,Infer可以分析 Objective-C, Java 或者 C 代碼,報告潛在的問題。Infer效率高,規模大,幾分鐘能掃描數千行代碼;
C/OC中捕捉的bug類型主要有:
1:Resource leak
只在 OC中捕捉的bug類型
1:Retain cycle
結論
Clang Static Analyzer和Xcode集成度更高、更好用,支持命令行形式,并且能夠用于持續集成。OCLint有更多的檢查規則和定制,和很多工具集成,也同樣可用于持續集成。Infer效率高,規模大,幾分鐘能掃描數千行代碼;支持增量及非增量分析;分解分析,整合輸出結果。infer能將代碼分解,小范圍分析后再將結果整合在一起,兼顧分析的深度和速度,所以根據自己的項目特點,選擇合適的檢查工具對代碼進行檢查,減少人力review成本,保證代碼質量,最大限度的避免運行錯誤。
3 測試過程
前面介紹了很多指標的監測,代碼靜態檢查,這些都是性能相關的,真正決定一個App功能穩定是否的是測試環節。測試是發布之前的最后一道卡,如果bug不能在測試中發現,那么最終就會觸達用戶,所以一個App的穩定性,很大程度決定它的測試過程。iOS App的測試包括以下幾個層次:單元測試,UI測試,功能測試,異常測試。
單元測試
XCTest是蘋果官方提供的單元測試框架,與Xcode集成在一起,由此蘋果提供了很詳細的文檔XCTest。
Xcode單元測試包含在一個XCTestCase的子類中。依據約束,每一個 XCTestCase 子類封裝一個特殊的有關聯的集合,例如一個功能、用例或者一個程序流。同時還提供了XCTestExpectation來處理異步任務的測試,以及性能測試measureBlock(),還包括很多第三方測試框架比如:KiWi,Quick,Specta等,以及常用的mock框架OCMock。
單元測試的目的是將程序中所有的源代碼,隔離成最小的可測試單元,以確保每個單元的正確性,如果每個單元都能保證正確,就能保證應用程序整體相當程度的正確性。但是在實際的操作過程中,很多公司都很難徹底執行單元測試,主要就是單元測試代碼量甚至大于功能開發,比較難于維護。
對于測試用例覆蓋度多少合適這個話題,也是仁者見仁智者見智,其實一個軟件覆蓋度在50%以上就可以稱為一個健壯的軟件了,要達到70,80這些已經是非常難了,不過我們常見的一些第三方開源框架的測試用例覆蓋率還是非常高的,讓人咋舌。例如,AFNNetWorking的覆蓋率高達87%,SDWebImage的覆蓋率高達77%。
UI測試
Xcode7中新增了UI Test測試,UI測試是模擬用戶操作,進而從業務處層面測試,常用第三方庫有KIF,appium。關于XCTest的UI測試,建議看看WWDC 2015的視頻UI Testing in Xcode。 UI測試還有一個核心功能是UI Recording。選中一個UI測試用例,然后點擊圖中的小紅點既可以開始UI Recoding。你會發現:隨著點擊模擬器,自動合成了測試代碼。(通常自動合成代碼后,還需要手動的去調整)
UI測試
功能測試
功能測試跟上述的UT和UI測試有一些相通的地方,首先針對各個模塊設計的功能,測試是否達到產品的目的,通常功能測試主要是測試及產品人員,然后還需要進行專項測試,比如我們公司的云測平臺,會對整個App的性能,穩定性,UI等都進行整體評測,看是否達到標準,對于大規模的活動,還需要進行服務端的壓力測試,確保整個功能無異常。測試通過后,可以進行estFlight測試,到最后正式發布。
功能測試還包括如下場景:系統兼容性測試,屏幕分辨率兼容性測試,覆蓋安裝測試,UI是否符合設計,消息推送等,以及前面開發過程中需要監控的內存、cpu、電量、網絡流量、冷啟動時間、熱啟動時間、存儲、安裝包的大小等測試。
異常測試
異常測試主要是針對一些不常規的操作
使用過程中的來電時及結束后,界面顯示是否正常;
狀態欄為兩倍高度時,界面是否顯示正常;
意外斷電后,數據是否保存,數據是否有損害等;
設備充電時,不同電量時的App響應速度及操作流暢度等;
其他App的相互切換,前后臺轉換時,是否正常;
網絡變化時的提示,弱網環境下的網絡請求成功率等;
各種monkey的隨機點擊,多點觸摸測試等是否正常;
更改系統時間,字體大小,語言等顯示是否正常;
設備存儲不夠時,是否能正常操作;
...
異常測試有很多,App針對自身的特點,可以選擇性的進行邊界和異常測試,也是保證App穩定行的一個重要方面。
4 發布及監控
因為移動App的特點,即使我們通過了各種測試,產品最終發布后,還是會遇到很多問題,比如Crash,網絡失敗,數據損壞,賬號異常等等。針對已經發布的App,主要有一下方式保證穩定性:
熱修復
目前比較流行的熱修復方案都是基于JSPatch、React Native、Weex、lua+wax。
JSPatch能做到通過js調用和改寫OC方法。最根本的原因是 Objective-C 是動態語言,OC上所有方法的調用/類的生成都通過 objective-c Runtime 在運行時進行,我們可以通過類名和方法名反射得到相應的類和方法,也可以替換某個類的方法為新的實現,還可以新注冊一個類,為類添加方法。JSPatch 的原理就是:JS傳遞字符串給OC,OC通過 Runtime 接口調用和替換OC方法。
React Native 是從 Web 前端開發框架 React 延伸出來的解決方案,主要解決的問題是 Web 頁面在移動端性能低的問題,React Native 讓開發者可以像開發 Web 頁面那樣用 React 的方式開發功能,同時框架會通過 JavaScript 與 Objective-C 的通信讓界面使用原生組件渲染,讓開發出來的功能擁有原生App的性能和體驗。
Weex阿里開源的,基于Vue+Native的開發模式,跟RN的主要區別就在React和Vue的區別,同時在RN的基礎上進行了部分性能優化,總體開發思路跟RN是比較像的。
但是在今年上半年,蘋果以安全為理由,開始拒絕有熱修復功能的應用,但其實蘋果拒的不是熱更新,拒的是從網絡下載代碼并修改應用行為,蘋果禁止的是“基于反射的熱更新“,而不是 “基于沙盒接口的熱更新”。而大部分框架(如 React Native、weex)和游戲引擎(比如 Unity、Cocos2d-x等)都屬于后者,所以不在被警告范圍內。而JSPatch因為在國內大部分應用來做熱更新修復bug的行為,所以才回被蘋果禁止。
降級
用戶使用App一段時間后,可能會遇到這樣的情況:每次打開App時閃退,或者正常操作到某個界面時閃退,無法正常使用App。這樣的用戶體驗十分糟糕,如果沒有一個好的解決方案,很容易被用戶刪除App,導致用戶量的流失。因為熱更新基本不能使用,那就只能是App自身修復能力。目前常用的修復能力有:
啟動Crash的監控及修復
1 在應用起來的時候,記錄flag并保存本地,啟動一個定時器,比如5秒鐘內,如果沒有發生Crash,則認為用戶操作正常,清空本地flag。
2 下次啟動,發現有flag,則表明上次啟動Crash,如果flag數組越大,則說明Crash的次數越多,這樣就需要對整個App進行降級處理,比如登出賬號,清空Documents/Library/Caches目錄下的文件。
具體業務下的Crash及修復
針對某些具體業務Crash場景,如果是上線的前端頁面引起的,可以先對前端功能進行回滾,或者隱藏入口,等修復完畢后再上線,如果是客戶端的某些異常,比如數據庫升遷問題,主要是進行業務數據庫修復,緩存文件的刪除,賬號退出等操作,盡量只修復此業務的相關的數據。
網絡降級
比如點評App,本身有CIP(公司內部自己研發的)長連接,接入騰訊云的WNS長連接,UDP連接,HTTP短連接,如果CIP服務器發生問題,可以及時切換到WNS連接,或者降級到Http連接,保證網絡連接的成功率。
線上監控
Crash監控
Crash是對用戶來說是最糟糕的體驗,Crash日志能夠記錄用戶閃退的崩潰日志及堆棧,進程線程信息,版本號,系統版本號,系統機型等有用信息,收集的信息越詳細,越能夠幫助解決崩潰,所以各大App都有自己崩潰日志收集系統,或者也可以使用開源或者付費的第三方Crash收集平臺。
端到端成功率監控
端到端監控是從客戶端App發出請求時計時,到App收到數據數據的成功率,統計對象是:網絡接口請求(包括H5頁面加載)的成敗和端到端延時情況。端到端監控SDK提供了監控上傳接口,調用SDK提供的監控API可以將數據上報到監控服務器中。
整個端到端監控的可以在多個維度上做查詢端到端成功率、響應時間、訪問量的查詢,維度包括:返回碼、網絡、版本、平臺、地區、運營商等。
用戶行為日志
用戶行為日志,主要記錄用戶在使用App過程中,點擊元素的時間點,瀏覽時長,跳轉流程等,然后基于此進行用戶行為分析,大部分應用的推薦算法都是基于用戶行為日志來統計的。某些情況下,Crash分析需要查詢用戶的行為日志,獲取用戶使用App的流程,幫助解決Crash等其他問題。
代碼級日志
代碼級別的日志,主要用來記錄一個App的性能相關的數據,比如頁面打開速度,內存使用率,CPU占用率,頁面的幀率,網絡流量,請求錯誤統計等,通過收集相關的上下文信息,優化App性能。
總結
雖然現在市面上第三方平臺已經很成熟,但是各大互聯公司都會自己開發線上監控系統,這樣保證數據安全,同時更加靈活。因為移動用戶的特點,在開發測試過程中,很難完全覆蓋所有用戶的全部場景,有些問題也只會在特定環境下才發生,所以通過線上監控平臺,通過日志回撈等機制,及時獲取特定場景的上下文環境,結合數據分析,能夠及時發現問題,并后續修復,提高App的穩定性。
全文總結
本文主要從開發測試發布等流程來介紹了一個App穩定性指標及監測方法,開發階段主要針對一些比較具體的指標,靜態檢查主要是掃描代碼潛在問題,然后通過測試保證App功能的穩定性,線上降級主要是在盡量不發版的情況下,進行自修復,配合線上監控,信息收集,用戶行為記錄,方便后續問題修復及優化。本文觀點是作者從事iOS開發的一些經驗,希望能對你有所幫助,觀點不同歡迎討論。
*請認真填寫需求信息,我們會在24小時內與您取得聯系。