Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537 Warning: error_log(/data/www/wwwroot/hmttv.cn/caches/error_log.php): failed to open stream: Permission denied in /data/www/wwwroot/hmttv.cn/phpcms/libs/functions/global.func.php on line 537
多國內(nèi)企業(yè)正積極開拓國際市場,如Shopee、阿里的Lazada、字節(jié)的TikTok、拼多多海外版Temu、以及服裝快消領(lǐng)域的Shein等。當(dāng)國內(nèi)市場存量業(yè)務(wù)達(dá)到峰值預(yù)期時(shí),海外業(yè)務(wù)成為各公司未來收入增長的主要動力,因此,國際化已成為越來越重要的職業(yè)發(fā)展方向。
國內(nèi)IT企業(yè)收入天花板: 「10億X2元X365天=7300億元」,也就是10億人口,企業(yè)每天賺取用戶2元,保持365天,就是單業(yè)務(wù)增長的營收天花板(大部分業(yè)務(wù)賺不到2元,用戶量也沒到10億)。比如視頻如果60元一個(gè)月那會員營收天花板就可以這么預(yù)估. 甚至比這個(gè)還低, 畢竟用戶會流失, 拉新也要成本, 運(yùn)營成本是在遞增的。
國際化不僅僅是多語言文案適配這么簡單,而是一全套的工程化解決方案。筆者覺得更重要的是「從業(yè)人員需要具備全球視野,對多元文化有包容心和敬畏心理,同時(shí)知識面要求也較高」。比如,了解SEA、US、UK等常見地區(qū)的簡寫,尊重伊斯蘭教的齋月節(jié)等習(xí)俗。對于服務(wù)全球用戶的產(chǎn)品來說,對應(yīng)產(chǎn)品的要求更加復(fù)雜,多樣性體現(xiàn)在不同的文化習(xí)俗差異上,其實(shí)即便在龐大的中國內(nèi)部也存在南北差異。了解的越多越發(fā)現(xiàn)這個(gè)世界的“多樣性”。
蘋果鍵盤有很多型號不同型號的布局不一樣https://www.apple.com/shop/product/MK2A3J/A/magic-keyboard-japanese
apple-keyboard
那如何模仿蘋果造一把可以賣到世界各地的鍵盤?
其中2,3,4都是為產(chǎn)品的全球化服務(wù)
https://en.wikipedia.org/wiki/Internationalization_and_localization
globalization
產(chǎn)品面向全球用戶,需要做語言適配,針對不同國家地區(qū)的用戶提供對應(yīng)語言的版本。本質(zhì)是「文本替換」,也要考慮文本閱讀方向,比如阿拉伯語和希伯來語是從右到左。
可以看下Apple的做法,對不同國家地區(qū)提供不同服務(wù)
常見地區(qū)語言對應(yīng)關(guān)系可以看 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 是一個(gè)內(nèi)置的 JavaScript 對象,用于國際化(Internationalization)支持。它提供了處理日期、時(shí)間、數(shù)字格式化以及貨幣和語言的能力,以便網(wǎng)站能夠根據(jù)用戶的語言和地區(qū)習(xí)慣來顯示內(nèi)容。在 JavaScript 中,您可以使用 Intl 對象來執(zhí)行以下操作:
全地球有N個(gè)民族,有的民族有自己的語言, 有的民族用其他國家民族傳遞過來的語言, 融化吸收然后發(fā)展自己的文字。
按照ISO標(biāo)準(zhǔn)(https://zh.m.wikipedia.org/wiki/ISO_639-1),語言可以用大類+小類表示, 比如「zh」就是漢語,是一個(gè)大類,而「zh-CN」就是簡體中文的縮寫, 新加坡華人眾多久了就有「zh-SG」, 表示的是新加坡使用的中文,其次還有「zh-HK/zh-TW和zh-Hant/zh-Hans」等等
語言聲明是三段式結(jié)構(gòu) [language]-[script]-[region] , 如zh-Hans-CN表示中國地區(qū)簡體中文, zh-Hant表示所有中文繁體
Language Code Table(http://www.lingoes.net/zh/translator/langcode.htm)
一起來看下蘋果官網(wǎng)是如何適配多國語言的
澳門apple https://www.apple.com/mo/
香港apple
英文 https://www.apple.com/hk/en/
中文 https://www.apple.com/hk/
中國大陸地區(qū)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地區(qū)的path比如**/hk/**這樣來區(qū)分的,有的是直接換域名,比如中國大陸地區(qū)
按照普通的中文和英文順序,都是LTR,上到下,都是世界范圍通用的
而ar阿拉伯語, ur烏都語, he希伯來語都是特殊的從右到左, 即RTL的一般會通過標(biāo)簽的dir屬性標(biāo)識, 比如下面的解釋HTML dir Attribute(https://www.w3schools.com/tags/att_global_dir.asp)
HEBREW是指希伯來語,這是一種在以色列廣泛使用的語言,也是猶太教的宗教經(jīng)典文本的原始語言。它屬于阿夫羅亞細(xì)亞語系,有著悠久的歷史和文化價(jià)值。「希伯來語有其獨(dú)特的書寫系統(tǒng),從右向左書寫。」上圖的概念很少人普及, 因?yàn)榉菄H化產(chǎn)品不需要多語言, 做需要支持海外業(yè)務(wù)和全球應(yīng)用的同學(xué)可以多了解下. 傳統(tǒng)的英文, 中文簡體, 拉丁文等都是上圖LATIN的閱讀順序, 如果用上「top, 下bottom, 左left, 右right」代表我們的習(xí)慣, 也就是「Z」這樣的順序. 即行到行是從上到下的順序, 行內(nèi)閱讀順序是從左到右.
文檔流和閱讀順序
即left→right, top→bottom的順序,有主次分別,left→right的優(yōu)先級高于top→bottom
而Web標(biāo)準(zhǔn)對其定義是下面這樣的
講個(gè)笑話, 古代書籍就是按照 writing-mode: vertical-rl 排版的
joke
比如 margin: left 或者 text-align: left 在多語言場景都是不合適的,你的左右不是其他人的左右。
而應(yīng)該用 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;
也可以看下面的例子
如上兩個(gè)例子通過margin-inline-start等屬性,再在html元素上添加 dir: rtl 就可以實(shí)現(xiàn)多語言的閱讀順序兼容
由此, 常見的布局也會更新為以下形式,常見的物理盒模型用于尺寸計(jì)算, 邏輯盒模型用于國際化處理
盒子模型
上面寫了文檔有inline and block flow,對應(yīng)english的left和right,top和bottom。而 writing-mode 可以修改content-flows,比如下面的值
/* 關(guān)鍵值 */
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」獲取到元素滾動的距離,下圖是一個(gè)實(shí)際例子
scrollLeft的rtl
這里在最后做了一個(gè)遮罩(綠色邊框區(qū)域),內(nèi)部藍(lán)色部分類似一個(gè)走馬燈,通過overflow:hidden將藍(lán)色高亮部分超出的區(qū)域遮住
當(dāng)藍(lán)色部分滾動到最后,綠色遮罩隱藏,達(dá)到一個(gè)遮蓋,滾動到最后消失的效果,代碼如下
const ref=document.querySelector('.tiktok-table__container') // 父節(jié)點(diǎn),藍(lán)色區(qū)域
const ref2=document.querySelector('.tiktok-table__container > table') // 子節(jié)點(diǎn),表格區(qū)域
const bufferWidth=30 // 留一點(diǎn)buffer空間
if (ref && ref2 && ref.clientWidth + ref.scrollLeft >=ref2.clientWidth - bufferWidth) {
// 滾動到最后隱藏綠色遮罩
setTableRightMask(false)
} else {
setTableRightMask(true)
}
但是在RTL下,神奇的事情就發(fā)生了,scrollLeft居然是負(fù)數(shù)
這是因?yàn)镽TL的實(shí)現(xiàn)是通過HTML標(biāo)簽增加屬性 dir="rtl” 實(shí)現(xiàn)的,會將文檔完全翻轉(zhuǎn)過來,所以scrollLeft就會是負(fù)數(shù)。因?yàn)榇藭r(shí)(0, 0)這個(gè)原點(diǎn)已經(jīng)是表格右邊了
解決方法也很簡單,取絕對值唄,這樣就忽略了方向的影響
根據(jù)ISO標(biāo)準(zhǔn)對全球國家地區(qū)進(jìn)行劃分https://en.wikipedia.org/wiki/ISO_3166-2. 如 "US" 表示美國,"CN" 表示中國. 還有常見的如「zh-CN, en-US, en-GB等」
舉個(gè)說下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)可以理解為額外擴(kuò)展插件, 插件系統(tǒng)支持以下擴(kuò)展. 如上使用calendar擴(kuò)展和hourCycle擴(kuò)展
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標(biāo)簽才有, 其他標(biāo)簽也可以添加 -->
<a href="/path/to/german/version" hreflang="de" lang="de">Deutsche Version</a> <!-- a標(biāo)簽上可以用lang表示顯示的文本的語言, 也可以用hreflang表示跳轉(zhuǎn)頁面的語言 -->
語言屬性通常代表語言標(biāo)識符的主要部分。它標(biāo)識了用于表達(dá)語言的基本信息。例如,在 BCP 47 標(biāo)準(zhǔn)中,語言標(biāo)識符通常包含了語言的主要代碼部分,例如 "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
如果已經(jīng)有如上代碼, 再進(jìn)一步給這些內(nèi)容添加樣式是非常簡單的, 我們可以使用CSS的選擇器. 如 [lang|="fr"] 或者 :lang(fr)
[lang|="fr"] 選擇屬性 lang=fr 或者lang屬性以fr開頭的元素, 如 lang=fr-CA
腳本屬性是語言標(biāo)識符的可選部分,表示使用的書寫系統(tǒng)或文字的風(fēng)格。這是一個(gè)輔助信息,用于更精確地表示特定語言的書寫習(xí)慣。例如,"Hans" 代表簡體中文,"Latn" 代表拉丁文。
script的of支持傳入BCP47規(guī)范的二字碼(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標(biāo)簽的dir屬性設(shè)置
<html dir="ltr">
<html dir="rtl">
writing-mode: horizontal-tb;
writing-mode: vertical-lr;
writing-mode: vertical-rl;
文本行的布局方向, 以及塊的排列方向。如果想作用在整個(gè)文檔需要設(shè)置html標(biāo)簽, 則全局生效
「第一個(gè)屬性horizontal/vertical指的是行塊的排列, 第二個(gè)屬性則是指文本內(nèi)容的流向(content flows)」
形如 「?」 這種符號類,在多語言下是不一樣的
比如ar阿拉伯語和ur烏爾都斯語問號是RTL的,即 「?」(https://zh.m.wiktionary.org/wiki/%D8%9F)是OK的
而he希伯來語是LTR的,即 「?」 是OK的
是不是很神奇?一個(gè)問號也能玩出花來
線索管理頁-英文和阿拉伯語
常見的需要RTL的語言有下面這些
const rtlLangs=[
'ar', // 阿拉伯語
'ur', // 巴基斯坦
'he', // 以色列
'he-IL', // 希伯來語(以色列)
'fa-IR', // 波斯語(伊朗)
'ps' // 帕斯圖語
];
l10n本地化的一個(gè)比較多工作量的部分是文本的翻譯, 一種文本到N種文本的翻譯需要引入本地化團(tuán)隊(duì). 技術(shù)實(shí)現(xiàn)上選擇也很多
通過 key: text 映射, 比如 t('key') 最后程序跑出來就是text文案, 這種方式不會依賴其他東西, 跟普通網(wǎng)頁一樣內(nèi)容都是CDN文件. 缺點(diǎn)是文案做為靜態(tài)資源需要用戶額外獲取, 如果處理不好替換錯誤就展示 key 內(nèi)容而不是
以下例子以Vue為例, 配置如「en.json, fr.json」等等的靜態(tài)配置文案, 打包嵌入CDN的JS文件里
Vue i18n example:https://codesandbox.io/p/sandbox/o7n6pkpwoy?file=%2Fstore.js%3A10%2C14
程序運(yùn)行時(shí)通過接口拿文案,可以通過html標(biāo)簽添加query參數(shù) lang=xxx 標(biāo)記頁面語言, 或者cookie標(biāo)記語言選擇
加載翻譯的腳本, 在切換語言的時(shí)候替換掉加載的文本。好處是加載的腳本是當(dāng)前語言所需要的, 不會有其他語言的冗余. 缺點(diǎn)是依賴一個(gè)翻譯服務(wù), 如果翻譯服務(wù)宕機(jī)了網(wǎng)頁就不能正常訪問了
User -> gateway -> SSR -> i18n cache -> read-time translation services(實(shí)時(shí)翻譯服務(wù))
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可以根據(jù)傳入的值換取不同的表示,常用于國際化業(yè)務(wù)
I have {num, plural, one{{number} # apple} other{# apples}},but it's too small
如果你用過vim一定知道w(word)可以移動到下個(gè)單詞, 英文里把文本分為單詞、句子和段落,同理中文也是
const segmenter=new Intl.Segmenter('en-US', { granularity: 'word' });
const text='This is a sample text for demonstration purposes.';
// 使用 Segmenter 對文本進(jìn)行分割
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數(shù)組, 結(jié)構(gòu)類似正則, 有segment屬性
再舉個(gè)例子, 中文語境下「真的」其實(shí)是一個(gè)詞
// 創(chuàng)建分段器,指定語言環(huán)境和分段類型為'word'
const segmenter=new Intl.Segmenter(['en', 'zh'], { granularity: 'word' });
// 要分割的字符串
const text='Hello世界Hello world';
// 使用分段器分割字符串
const segments=segmenter.segment(text);
// 遍歷并打印每個(gè)分段的結(jié)果
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="我真的很強(qiáng), 強(qiáng)哥的強(qiáng)";
const segmenterJa=new Intl.Segmenter("zh-CN", { granularity: "word" });
const segments=segmenterJa.segment(str);
console.log(Array.from(segments));
// 0: {segment: '我', index: 0, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 1: {segment: '真的', index: 1, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 2: {segment: '很', index: 3, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 3: {segment: '強(qiáng)', index: 4, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 4: {segment: ',', index: 5, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: false}
// 5: {segment: ' ', index: 6, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: false}
// 6: {segment: '強(qiáng)', index: 7, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 7: {segment: '哥', index: 8, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 8: {segment: '的', index: 9, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
// 9: {segment: '強(qiáng)', index: 10, input: '我真的很強(qiáng), 強(qiáng)哥的強(qiáng)', isWordLike: true}
國際化會有時(shí)區(qū)劃分問題, 時(shí)區(qū)產(chǎn)生于太陽下地球自轉(zhuǎn)導(dǎo)致的晝夜交替. 而全球不同國家地區(qū)當(dāng)?shù)貢r(shí)間與UTC時(shí)間是不一致的. 全球大部分人都可以說自己早上起床, 晚上睡覺. 上下文是通的. 但是這個(gè)早上的時(shí)間根據(jù)UTC來定義是不一樣的
時(shí)間的往事--記一次與夏令時(shí)的斗智斗勇: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 標(biāo)準(zhǔn)時(shí)間 全球時(shí)區(qū)查詢:https://time.artjoey.com/cn
通過NTP協(xié)議(https://zh.wikipedia.org/wiki/%E7%B6%B2%E8%B7%AF%E6%99%82%E9%96%93%E5%8D%94%E5%AE%9A), 讓計(jì)算機(jī)在全球網(wǎng)絡(luò)里保持時(shí)間一致
東八區(qū)={CST(中國標(biāo)準(zhǔn)時(shí)),SGT(新加坡時(shí)間),AWST(澳洲西部標(biāo)準(zhǔn)時(shí))... }
// 所在地區(qū)的時(shí)區(qū)標(biāo)識符, 如 America/New_York
const timeZone=new Intl.DateTimeFormat().resolvedOptions().timeZone;
console.log("用戶時(shí)區(qū)偏移:" + timeZone); // 用戶時(shí)區(qū)偏移:Asia/Shanghai
// 獲取本地時(shí)間與UTC時(shí)間偏移值,最小單位是分鐘. 如"-480", 表示-8小時(shí). 其中正負(fù)表示UTC前后, 如美國東部時(shí)間是UTC-5, 中國北京時(shí)間是UTC+8
const date=new Date();
const timeZoneOffset=date.getTimezoneOffset();
console.log("時(shí)區(qū)偏移:" + timeZoneOffset); // 時(shí)區(qū)偏移:-480
Intl是新的瀏覽器API, 與Math類似是全局靜態(tài)對象, 專門用于處理國際化和本地化業(yè)務(wù). 其下的DateTimeFormat可以處理時(shí)間相關(guān)國際化問題
DST (Daylight saving time),日光節(jié)約時(shí),夏令時(shí)/冬令時(shí)等等名稱。「它會在每年春天的某一天將時(shí)鐘向后撥一小時(shí),又在秋天的某一天將時(shí)鐘向前撥動一個(gè)小時(shí)。」非國際化業(yè)務(wù)很少遇到這個(gè)情況,主要因?yàn)?strong>「中國不實(shí)行夏令時(shí)/冬令時(shí)。」
2021-03-14 01:59:59 GMT-08:00(太平洋標(biāo)準(zhǔn)時(shí)間,PST)
2021-03-14T01:59:59.000-08:00(ISO格式表示)
2021-03-14T09:59:59.000Z(轉(zhuǎn)換為UTC時(shí)間并以ISO格式表示)
// 下一秒時(shí)間突變
2021-03-14 03:00:00 GMT-07:00(太平洋夏令時(shí)間,PDT)
2021-03-14T03:00:00.000-07:00(ISO格式表示)
2021-03-14T10:00:00.000Z(轉(zhuǎn)換為UTC時(shí)間并以ISO格式表示)
// 原始時(shí)間字符串
const timeString="2021-03-14T09:59:59.000Z";
// 將時(shí)間字符串轉(zhuǎn)換為 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
// 獲取時(shí)間戳
const timestamp=date.getTime();
// 增加1秒
const newTimestamp=timestamp + 1000;
// 創(chuàng)建新的 Date 對象并格式化為 PDT 時(shí)間
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日期時(shí)間字符串
可以看這個(gè)demo
Days of the week:https://codesandbox.io/s/dayjs-dynamic-locale0import-forked-wnk2zq?file=/src/index.js
因我本地系統(tǒng)設(shè)置了每周第一天為星期日
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
// 創(chuàng)建 DateTimeFormat 對象,并指定語言和地區(qū)
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,用于格式化相對時(shí)間,例如“1 小時(shí)前”或“2 天后”。這個(gè) API 可以根據(jù)不同的語言和地區(qū)設(shè)置,以自然語言的方式呈現(xiàn)相對時(shí)間,使應(yīng)用程序能夠更好地適應(yīng)多語言環(huán)境。
const rtf1=new Intl.RelativeTimeFormat('zh', { style: 'short' });
console.log(rtf1.format(3, 'quarter'));
// Expected output: "3個(gè)季度后"
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位數(shù)字. 比如 1 2345 6789或者1’2345’6789(’是萬位分隔符)可以一眼看出來是一億兩千三百四十五萬六千七百八十九. 而如果是123, 456, 789可能很多人會愣很久重新數(shù)才知道是多少. 但是現(xiàn)在很多銀行APP都在推跟歐美一樣的屬于后者的千位分隔符. 可以看這篇討論覺得寫的在理
設(shè)計(jì)產(chǎn)品時(shí),你是如何掉入從眾的陷阱中的?– 人人都是產(chǎn)品經(jīng)理:https://www.woshipm.com/pd/1500589.html)
類似以上例子可以再看下面的舉例, 可以發(fā)現(xiàn)在德語和法語下, 千分位分隔符分別是.和 (空格)
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
英文復(fù)數(shù)是要加s的, 比如apples
const numbers=[1, 2, 5, 10, 100];
for (const number of numbers) {
const pluralRules=new Intl.PluralRules('en-US'); // 使用英語環(huán)境
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. 聰明的你一定發(fā)現(xiàn)規(guī)律了. 除了123后面就是數(shù)字+th. 簡寫是1st 2nd. 根據(jù)下表可以發(fā)現(xiàn)規(guī)律
數(shù)字英文第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'
常見的整數(shù)分隔符號有千分位分隔, 比如 1000,000 也有萬位分隔比如 1000 0000 . 不同語言不一樣
常見的小數(shù)分隔符號 . , 比如 1000.00 . 不同語言不一樣
const number=1234567.89;
// 格式化為默認(rèn)數(shù)字格式
const formattedNumber=new Intl.NumberFormat().format(number);
console.log(formattedNumber); // 輸出: 1,234,567.89
// 格式化為指定語言環(huán)境的數(shù)字格式
const formattedNumberDE=new Intl.NumberFormat('de-DE').format(number);
console.log(formattedNumberDE); // 輸出: 1.234.567,89
// 格式化為指定語言環(huán)境的數(shù)字格式
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
也可以通過參數(shù)配置控制小數(shù)部分最多/最少有多少位
const number=1234567.89123;
const formattedNumber=new Intl.NumberFormat('en-US', {
style: 'decimal', // 可選 'decimal' 表示常規(guī)數(shù)字格式
maximumFractionDigits: 3, // 小數(shù)部分最多顯示三位
}).format(number);
console.log(formattedNumber); // 輸出: 1,234,567.891
正常百分比是0-100數(shù)字+%, 但是法語環(huán)境百分比符號習(xí)慣是 '% '而不是'%', 多了一個(gè)空格
const percentage=0.75;
// 使用默認(rèn)語言環(huán)境
const formattedPercentageDefault=new Intl.NumberFormat('fr-FR', {
style: 'percent'
}).format(percentage);
console.log(formattedPercentageDefault); // 輸出: '75 %'
// 使用指定語言環(huán)境
const formattedPercentageFR=new Intl.NumberFormat('fr-FR', {
style: 'percent',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(percentage);
console.log(formattedPercentageFR); // 輸出: '75,00 %'
// 使用默認(rèn)語言環(huán)境
const formattedPercentageUS=new Intl.NumberFormat('en-US', {
style: 'percent'
}).format(percentage);
console.log(formattedPercentageUS); // 輸出: '75%'
// 使用指定語言環(huán)境
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 // ''
用常見的幾個(gè)經(jīng)濟(jì)體和身邊用的多的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 實(shí)現(xiàn), 也可以用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")); // "人民幣"
常見的電話本, 地址簿排序. 不同語言因?yàn)樽帜皋D(zhuǎn)換后排序不一致. 「Intl.Collator」 是 JavaScript 的國際化 API 之一,用于字符串比較和排序,以便在多語言環(huán)境中執(zhí)行正確的排序操作。它允許你創(chuàng)建一個(gè) 「Collator」 對象,用于根據(jù)特定語言和區(qū)域設(shè)置執(zhí)行字符串比較和排序,考慮到不同語言的差異。
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"]
//創(chuàng)建一個(gè)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)); // ['光', '今', '媚', '明', '天', '陽']
可以發(fā)現(xiàn) options可以傳遞參數(shù)usage和sensitivity, 有如下取值
有如下的應(yīng)用方式
const collator=new Intl.Collator('en-US', { sensitivity: 'base', usage: 'sort' }); //創(chuàng)建一個(gè)Intl.Collator對象
const result=collator.compare('apple', 'Banana');
console.log(result); // 根據(jù)配置輸出 -1(apple 在 Banana 前面)
我們知道英文字母默認(rèn)按照ASCII排序, 而如果需要AaBb這樣排序只能自己寫排序回調(diào)
// 創(chuàng)建一個(gè)自定義Collator對象
const customCollator=new Intl.Collator('en-US', {
sensitivity: 'base',
usage: 'sort',
ignorePunctuation: true,
caseFirst: 'false',
});
// 自定義比較函數(shù), 忽略空格并不區(qū)分大小寫
function customCompare(a, b) {
// 移除字符串中的空格并轉(zhuǎn)為小寫后再比較
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回調(diào)排序
console.log(data.sort(customCompare)); // 輸出排序結(jié)果:['Apple', 'apple pie', 'banana', 'Banana Split', 'cherry', 'cherry tart']
// 新方式: 使用自定義Collator對象進(jìn)行排序
console.log(data2.sort(customCollator.compare)); // 輸出排序結(jié)果:['Apple', 'apple pie', 'banana', 'Banana Split', 'cherry', 'cherry tart']
可以發(fā)現(xiàn)兩種方式結(jié)果一樣, 但是明顯Intl. Collator更加優(yōu)雅, 是配置化的.
「Intl.ListFormat」 是 JavaScript 的國際化 API 之一,它用于格式化列表,以便在多語言環(huán)境中創(chuàng)建自然語言的列表表示。Intl.ListFormat 允許你指定列表項(xiàng)的連接方式(如逗號、"和" 等),以及列表項(xiàng)的樣式和語言設(shè)置。
const listFormatter=new Intl.ListFormat('en-US', { style: 'long', type: 'disjunction' });
const items=['apples', 'bananas', 'cherries'];
const formattedList=listFormatter.format(items);
console.log(formattedList); // 根據(jù)配置輸出例如:"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); // 根據(jù)配置輸出例如:"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); // 根據(jù)配置輸出例如:"apples, bananas, cherries"
可以發(fā)現(xiàn) options可以傳遞參數(shù)style和type, 有如下取值
日歷是一種常見的東西, 在中國我們經(jīng)常接觸到公歷和農(nóng)歷,公歷全稱格里高利歷, 英文gregory。
現(xiàn)在國家節(jié)日很多都是跟隨農(nóng)歷的,比如春節(jié),中秋節(jié)等。以前家家人手一本農(nóng)歷, 上面會今日宜做什么, 現(xiàn)在很少見但是老人家還是信這個(gè)。
而與此相同, 每個(gè)地方都有自己的歷法
const date=new Date(); // 當(dāng)前日期, Mon Oct 30 2023 20:00:50 GMT+0800 (中國標(biāo)準(zhǔn)時(shí)間)
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. 首先日本還有皇帝, 有皇帝就有年號. 常見下面的一些年份
可以看到日本的日歷起始時(shí)周日(日), 但是周一到周六分別對應(yīng)月火水木金土. 與眾不同
// 獲取今天過去7天的日期
// 日期對象
const today=new Date();
// 創(chuàng)建一個(gè)選項(xiàng)對象,指定輸出的語言和風(fēng)格
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, 紀(jì)元的意思
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')); // 輸出:時(shí)代
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')); // 輸出:時(shí)
console.log(dateTimeFields.of('minute')); // 輸出:分
console.log(dateTimeFields.of('second')); // 輸出:秒
console.log(dateTimeFields.of('quarter')); // 輸出:四半期
en-US一般每周第一天是周日, 而zh-CN一般每周第一天是周一. 可以通過如下信息判斷
Intl. Locale函數(shù)返回屬性里firstDay是一個(gè)數(shù)字,其中 0或7 表示星期日,1 表示星期一,依此類推。不同的地區(qū)和文化可能會將每周的第一天設(shè)置為不同的日期,因此這個(gè)屬性可以幫助你確定每周的起始日期,例如,星期天或星期一。
(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) 等價(jià)于 new Date(1900,0,7) 對應(yīng)1900.1.7(UTC+0), 此時(shí)對應(yīng)us的sunday. 而 new Date(0,0,1) 對應(yīng)1900.1.1(UTC+0), 對應(yīng)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); // 輸出所有支持的日歷系統(tǒng)
// (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); // 輸出所有支持的時(shí)區(qū)
// (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, 用于處理國際化相關(guān)內(nèi)容. 附帶browerstack 云真機(jī)測試工具(caniuse推薦): https://live.browserstack.com/dashboard如果沒處理好兼容性問題直接使用API, 會報(bào)JS Error. 內(nèi)容為Intl.DisplayNames is not a constructor
對應(yīng)的, 一些操作系統(tǒng)低版本的用戶(長期不升級系統(tǒng))會遇到JS Error導(dǎo)致白屏
Google pixel4(2019, October 15發(fā)行)
可以用formatjs提供的polyfill做低版本兼容: https://formatjs.io/docs/getting-started/installation
async function loadPolyfill() {
// 如果當(dāng)前環(huán)境不支持 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 {
// 當(dāng)前環(huán)境支持 Intl.DisplayNames API,不需要 Polyfill
return true
}
}
眾所周知Babel有一個(gè)babel-preset-env(https://www.babeljs.cn/docs/babel-preset-env#how-does-it-work), 用于在編譯代碼時(shí)智能(基于core-js-compat(https://www.npmjs.com/package/core-js-compat))引入helper和polyfill 智能的含義: 可以設(shè)置最低兼容的瀏覽器(https://github.com/browserslist/browserslist#queries)和代碼, 動態(tài)引用所需的helper和polyfill
// babel.config.js
module.exports={
presets: [
[
'@babel/preset-env',
{
useBuiltIns: 'usage', // 根據(jù)每個(gè)文件里面,用到了哪些es的新特性和targets導(dǎo)入polyfill,更加精簡
corejs: 3, // 指定 core-js 版本
targets: "> 0.25%, not dead" // 指定目標(biāo)瀏覽器, 選取全球使用率超過 0.25% 的瀏覽器版本
},
],
],
};
「babel底層使用core-js(https://github.com/zloirock/core-js)進(jìn)行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
來源-微信公眾號:字節(jié)前端 ByteFE
出處:https://mp.weixin.qq.com/s/PByp6Pmc3vp7b0acyPT8yA
節(jié)跳動面試官:請你實(shí)現(xiàn)一個(gè)大文件上傳和斷點(diǎn)續(xù)傳
原 作 者:yeyan1996
原文鏈接:https://url.cn/5h66afn
這段時(shí)間面試官都挺忙的,頻頻出現(xiàn)在博客文章標(biāo)題,雖然我不是特別想蹭熱度,但是實(shí)在想不到好的標(biāo)題了-。-,蹭蹭就蹭蹭 :)
事實(shí)上我在面試的時(shí)候確實(shí)被問到了這個(gè)問題,而且是一道在線 coding 的編程題,當(dāng)時(shí)雖然思路正確,可惜最終也并不算完全答對。
結(jié)束后花了一段時(shí)間整理了下思路,那么究竟該如何實(shí)現(xiàn)一個(gè)大文件上傳,以及在上傳中如何實(shí)現(xiàn)斷點(diǎn)續(xù)傳的功能呢?
本文將從零搭建前端和服務(wù)端,實(shí)現(xiàn)一個(gè)大文件上傳和斷點(diǎn)續(xù)傳的 demo:
文章有誤解的地方,歡迎指出,將在第一時(shí)間改正,有更好的實(shí)現(xiàn)方式希望留下你的評論。
前端大文件上傳網(wǎng)上的大部分文章已經(jīng)給出了解決方案,核心是利用 Blob.prototype.slice 方法,此方法和數(shù)組的 slice 方法相似,調(diào)用的 slice 方法可以返回原文件的某個(gè)切片。
這樣我們就可以根據(jù)預(yù)先設(shè)置好的切片最大數(shù)量將文件切分為一個(gè)個(gè)切片,然后借助 http 的可并發(fā)性,同時(shí)上傳多個(gè)切片,這樣從原本傳一個(gè)大文件,變成了同時(shí)傳多個(gè)小的文件切片,可以大大減少上傳時(shí)間。
另外由于是并發(fā),傳輸?shù)椒?wù)端的順序可能會發(fā)生變化,所以我們還需要給每個(gè)切片記錄順序。
服務(wù)端需要負(fù)責(zé)接受這些切片,并在接收到所有切片后合并切片。
這里又引伸出兩個(gè)問題:
第一個(gè)問題需要前端進(jìn)行配合,前端在每個(gè)切片中都攜帶切片最大數(shù)量的信息,當(dāng)服務(wù)端接收到這個(gè)數(shù)量的切片時(shí)自動合并,也可以額外發(fā)一個(gè)請求主動通知服務(wù)端進(jìn)行切片的合并。
第二個(gè)問題,具體如何合并切片呢?這里可以使用 NodeJS 的 API fs.appendFileSync,它可以同步地將數(shù)據(jù)追加到指定文件,也就是說,當(dāng)服務(wù)端接收完所有切片后,可以先創(chuàng)建一個(gè)空文件,然后將所有切片逐步合并到這個(gè)文件中。
so,talk is cheap, show me the code,接著讓我們用代碼實(shí)現(xiàn)上面的思路吧。
前端使用 Vue 作為開發(fā)框架,對界面沒有太大要求,原生也可以,考慮到美觀使用 Element-UI 作為 UI 框架。
首先創(chuàng)建選擇文件的控件,監(jiān)聽 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 做一層簡單的封裝來發(fā)請求:
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
});
};
});
}
接著實(shí)現(xiàn)比較重要的上傳功能,上傳需要做兩件事:
<template>
<div>
<input type="file" @change="handleFileChange" />
<el-button @click="handleUpload">上傳</el-button>
</div>
</template>
<script>
+ const LENGTH=10; // 切片數(shù)量
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); // 并發(fā)切片
+ },
+ 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 // 文件名 + 數(shù)組下標(biāo)
+ }));
+ await this.uploadChunks();
+ }
}
};
</script>
當(dāng)點(diǎn)擊上傳按鈕時(shí),調(diào)用 createFileChunk 將文件切片,切片數(shù)量通過一個(gè)常量 Length 控制,這里設(shè)置為 10,即將文件分成 10 個(gè)切片上傳。
createFileChunk 內(nèi)使用 while 循環(huán)和 slice 方法將切片放入 fileChunkList 數(shù)組中返回。
在生成文件切片時(shí),需要給每個(gè)切片一個(gè)標(biāo)識作為 hash,這里暫時(shí)使用文件名 + 下標(biāo),這樣后端可以知道當(dāng)前切片是第幾個(gè)切片,用于之后的合并切片。
隨后調(diào)用 uploadChunks 上傳所有的文件切片,將文件切片,切片 hash,以及文件名放入 FormData 中,再調(diào)用上一步的 request 函數(shù)返回一個(gè) proimise,最后調(diào)用 Promise.all 并發(fā)上傳所有的切片。
這里使用整體思路中提到的第二種合并切片的方式,即前端主動通知服務(wù)端進(jìn)行合并,所以前端還需要額外發(fā)請求,服務(wù)端接受到這個(gè)請求時(shí)主動合并切片
<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() {},
// 上傳切片,同時(shí)過濾已上傳的切片
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 模塊搭建服務(wù)端:
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("正在監(jiān)聽 3000 端口"));
使用 multiparty 包處理前端傳來的 FormData,在 multiparty.parse 的回調(diào)中,files 參數(shù)保存了 FormData 中文件,fields 參數(shù)保存了 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}`;
+ // 切片目錄不存在,創(chuàng)建切片目錄
+ if (!fse.existsSync(chunkDir)) {
+ await fse.mkdirs(chunkDir);
+ }
+ // fs-extra 專用方法,類似 fs.rename 并且跨平臺
+ // fs-extra 的 rename 方法 windows 平臺會有權(quán)限問題
+ // 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("正在監(jiān)聽 3000 端口"));
查看 multiparty 處理后的 chunk 對象,path 是存儲臨時(shí)文件的路徑,size 是臨時(shí)文件大小,在 multiparty 文檔中提到可以使用 fs.rename(由于我用的是 fs-extra,其 rename 方法在 Windows 系統(tǒng)上存在權(quán)限問題,所以換成了 fse.move) 重命名的方式移動臨時(shí)文件,也就是文件切片。
在接受文件切片時(shí),需要先創(chuàng)建存儲切片的文件夾,由于前端在發(fā)送每個(gè)切片時(shí)額外攜帶了唯一值 hash,所以以 hash 作為文件名,將切片從臨時(shí)路徑移動切片文件夾中,最后的結(jié)果如下
在接收到前端發(fā)送的合并請求后,服務(wù)端將文件夾下的所有切片進(jìn)行合并
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("正在監(jiān)聽 3000 端口"));
由于前端在發(fā)送合并請求時(shí)會攜帶文件名,服務(wù)端根據(jù)文件名可以找到上一步創(chuàng)建的切片文件夾。
接著使用 fs.writeFileSync 先創(chuàng)建一個(gè)空文件,這個(gè)空文件的文件名就是切片文件夾名 + 后綴名組合而成,隨后通過 fs.appendFileSync 從切片文件夾中不斷將切片合并到空文件中,每次合并完成后刪除這個(gè)切片,等所有切片都合并完畢后最后刪除切片文件夾。
至此一個(gè)簡單的大文件上傳就完成了,接下來我們再此基礎(chǔ)上擴(kuò)展一些額外的功能。
上傳進(jìn)度分兩種,一個(gè)是每個(gè)切片的上傳進(jìn)度,另一個(gè)是整個(gè)文件的上傳進(jìn)度,而整個(gè)文件的上傳進(jìn)度是基于每個(gè)切片上傳進(jìn)度計(jì)算而來,所以我們需要先實(shí)現(xiàn)切片的上傳進(jìn)度。
XMLHttpRequest 原生支持上傳進(jìn)度的監(jiān)聽,只需要監(jiān)聽 upload.onprogress 即可,我們在原來的 request 基礎(chǔ)上傳入 onProgress 參數(shù),給 XMLHttpRequest 注冊監(jiān)聽事件:
// 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
});
};
});
}
由于每個(gè)切片都需要觸發(fā)獨(dú)立的監(jiān)聽事件,所以還需要一個(gè)工廠函數(shù),根據(jù)傳入的切片返回不同的監(jiān)聽函數(shù)。
在原先的前端上傳邏輯中新增監(jiān)聽函數(shù)部分:
// 上傳切片,同時(shí)過濾已上傳的切片
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));
+ };
+ }
每個(gè)切片在上傳時(shí)都會通過監(jiān)聽函數(shù)更新 data 數(shù)組對應(yīng)元素的 percentage 屬性,之后把將 data 數(shù)組放到視圖中展示即可。
將每個(gè)切片已上傳的部分累加,除以整個(gè)文件的大小,就能得出當(dāng)前文件的上傳進(jìn)度,所以這里使用 Vue 計(jì)算屬性:
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));
}
}
最終效果如下:
斷點(diǎn)續(xù)傳的原理在于前端/服務(wù)端需要記住已上傳的切片,這樣下次上傳就可以跳過之前已上傳的部分,有兩種方案實(shí)現(xiàn)記憶的功能:
第一種是前端的解決方案,第二種是服務(wù)端,而前端方案有一個(gè)缺陷,如果換了個(gè)瀏覽器就失去了記憶的效果,所以這里選取后者。
無論是前端還是服務(wù)端,都必須要生成文件和切片的 hash,之前我們使用文件名 + 切片下標(biāo)作為切片 hash,這樣做文件名一旦修改就失去了效果,而事實(shí)上只要文件內(nèi)容不變,hash 就不應(yīng)該變化,所以正確的做法是根據(jù)文件內(nèi)容生成 hash,所以我們需要修改一下 hash 的生成規(guī)則。
這里用到另一個(gè)庫 spark-md5,它可以根據(jù)文件內(nèi)容計(jì)算出文件的 hash 值,另外考慮到如果上傳一個(gè)超大文件,讀取文件內(nèi)容計(jì)算 hash 是非常耗費(fèi)時(shí)間的,并且會引起 UI 的阻塞,導(dǎo)致頁面假死狀態(tài),所以我們使用 web-worker 在 worker 線程計(jì)算 hash,這樣用戶仍可以在主界面正常的交互。
由于實(shí)例化 web-worker 時(shí),參數(shù)是一個(gè) JavaScript 文件路徑,且不能跨域。所以我們單獨(dú)創(chuàng)建一個(gè) hash.js 文件放在 public 目錄下,另外在 worker 中也是不允許訪問 DOM 的,但它提供了importScripts 函數(shù)用于導(dǎo)入外部腳本,通過它導(dǎo)入 spark-md5。
// /public/hash.js
self.importScripts("/spark-md5.min.js"); // 導(dǎo)入腳本
// 生成文件 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
});
// 遞歸計(jì)算下一個(gè)切片
loadNext(count);
}
};
};
loadNext(0);
};
在 worker 線程中,接受文件切片 fileChunkList,利用 FileReader 讀取每個(gè)切片的 ArrayBuffer 并不斷傳入 spark-md5 中,每計(jì)算完一個(gè)切片通過 postMessage 向主線程發(fā)送一個(gè)進(jìn)度事件,全部完成后將最終的 hash 發(fā)送給主線程。
spark-md5 需要根據(jù)所有切片才能算出一個(gè) hash 值,不能直接將整個(gè)文件放入計(jì)算,否則即使不同文件也會有相同的 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, // 文件名 + 數(shù)組下標(biāo)
percentage:0
}));
await this.uploadChunks();
}
主線程使用 postMessage 給 worker 線程傳入所有切片 fileChunkList,并監(jiān)聽 worker 線程發(fā)出的 postMessage 事件拿到文件 hash。
加上顯示計(jì)算 hash 的進(jìn)度條,看起來像這樣
至此前端需要將之前用文件名作為 hash 的地方改寫為 workder 返回的這個(gè) hash。
服務(wù)端則使用 hash 作為切片文件夾名,hash + 下標(biāo)作為切片名,hash + 擴(kuò)展名作為文件名,沒有新增的邏輯。
在實(shí)現(xiàn)斷點(diǎn)續(xù)傳前先簡單介紹一下文件秒傳。
所謂的文件秒傳,即在服務(wù)端已經(jīng)存在了上傳的資源,所以當(dāng)用戶再次上傳時(shí)會直接提示上傳成功
文件秒傳需要依賴上一步生成的 hash,即在上傳前,先計(jì)算出文件 hash,并把 hash 發(fā)送給服務(wù)端進(jìn)行驗(yàn)證,由于 hash 的唯一性,所以一旦服務(wù)端能找到 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();
}
秒傳其實(shí)就是給用戶看的障眼法,實(shí)質(zhì)上根本沒有上傳。就像下面這行代碼 :)
服務(wù)端的邏輯非常簡單,新增一個(gè)驗(yàn)證接口,驗(yàn)證文件是否存在即可。
+ 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("正在監(jiān)聽 3000 端口"));
講完了生成 hash 和文件秒傳,回到斷點(diǎn)續(xù)傳。
斷點(diǎn)續(xù)傳顧名思義即斷點(diǎn) + 續(xù)傳,所以我們第一步先實(shí)現(xiàn)"斷點(diǎn)",也就是暫停上傳。
原理是使用 XMLHttpRequest 的 abort 方法,可以取消一個(gè) xhr 請求的發(fā)送,為此我們需要將上傳每個(gè)切片的 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
});
};
+ // 暴露當(dāng)前 xhr 給外部
+ requestList?.push(xhr);
});
},
這樣在上傳切片時(shí)傳入 requestList 數(shù)組作為參數(shù),request 方法就會將所有的 xhr 保存在數(shù)組中了。
每當(dāng)一個(gè)切片上傳成功時(shí),將對應(yīng)的 xhr 從 requestList 中刪除,所以 requestList 中只保存正在上傳切片的 xhr。
之后新建一個(gè)暫停按鈕,當(dāng)點(diǎn)擊按鈕時(shí),調(diào)用保存在 requestList 中 xhr 的 abort 方法,即取消并清空所有正在上傳的切片。
handlePause() {
this.requestList.forEach(xhr=> xhr?.abort());
this.requestList=[];
}
點(diǎn)擊暫停按鈕可以看到 xhr 都被取消了。
之前在介紹斷點(diǎn)續(xù)傳的時(shí)提到使用第二種服務(wù)端存儲的方式實(shí)現(xiàn)續(xù)傳
由于當(dāng)文件切片上傳后,服務(wù)端會建立一個(gè)文件夾存儲所有上傳的切片,所以每次前端上傳前可以調(diào)用一個(gè)接口,服務(wù)端將已上傳的切片的切片名返回,前端再跳過這些已經(jīng)上傳切片,這樣就實(shí)現(xiàn)了"續(xù)傳"的效果
而這個(gè)接口可以和之前秒傳的驗(yàn)證接口合并,前端每次上傳前發(fā)送一個(gè)驗(yàn)證的請求,返回兩種結(jié)果:
所以我們改造一下之前文件秒傳的服務(wù)端驗(yàn)證接口:
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));
});
});
+ // 返回已經(jīng)上傳切片名列表
+ 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("正在監(jiān)聽 3000 端口"));
接著回到前端,前端有兩個(gè)地方需要調(diào)用驗(yàn)證的接口:
新增恢復(fù)按鈕并改造原來上傳切片的邏輯:
<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>恢復(fù)</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);
},
// 上傳切片,同時(shí)過濾已上傳的切片
+ 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);
// 之前上傳的切片數(shù)量 + 本次上傳的切片數(shù)量=所有切片數(shù)量時(shí)
// 合并切片
+ if (uploadedList.length + requestList.length===this.data.length) {
await this.mergeRequest();
+ }
}
這里給原來上傳切片的函數(shù)新增 uploadedList 參數(shù),即上圖中服務(wù)端返回的切片名列表,通過 filter 過濾掉已上傳的切片,并且由于新增了已上傳的部分,所以之前合并接口的觸發(fā)條件做了一些改動。
到這里斷點(diǎn)續(xù)傳的功能基本完成了。
雖然實(shí)現(xiàn)了斷點(diǎn)續(xù)傳,但還需要修改一下進(jìn)度條的顯示規(guī)則,否則在暫停上傳/接收到已上傳切片時(shí)的進(jìn)度條會出現(xiàn)偏差。
由于在點(diǎn)擊上傳/恢復(fù)上傳時(shí),會調(diào)用驗(yàn)證接口返回已上傳的切片,所以需要將已上傳切片的進(jìn)度變成 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 會返回已上傳的切片,在遍歷所有切片時(shí)判斷當(dāng)前切片是否在已上傳列表里即可。
之前說到文件進(jìn)度條是一個(gè)計(jì)算屬性,根據(jù)所有切片的上傳進(jìn)度計(jì)算而來,這就遇到了一個(gè)問題:
點(diǎn)擊暫停會取消并清空切片的 xhr 請求,此時(shí)如果已經(jīng)上傳了一部分,就會發(fā)現(xiàn)文件進(jìn)度條有倒退的現(xiàn)象:
當(dāng)點(diǎn)擊恢復(fù)時(shí),由于重新創(chuàng)建了 xhr 導(dǎo)致切片進(jìn)度清零,所以總進(jìn)度條就會倒退。
解決方案是創(chuàng)建一個(gè)"假"的進(jìn)度條,這個(gè)假進(jìn)度條基于文件進(jìn)度條,但只會停止和增加,然后給用戶展示這個(gè)假的進(jìn)度條
這里我們使用 Vue 的監(jiān)聽屬性:
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;
+ }
}
},
當(dāng) uploadPercentage 即真的文件進(jìn)度條增加時(shí),fakeUploadPercentage 也增加,一旦文件進(jìn)度條后退,假的進(jìn)度條只需停止即可。
至此一個(gè)大文件上傳 + 斷點(diǎn)續(xù)傳的解決方案就完成了
大文件上傳:
斷點(diǎn)續(xù)傳:
源代碼增加了一些按鈕的狀態(tài),交互更加友好,文章表達(dá)比較晦澀的地方可以跳轉(zhuǎn)到源代碼查看
file-upload[2]
大家好,這里是 FEHub,每天早上 9 點(diǎn)更新,為你嚴(yán)選優(yōu)質(zhì)文章,與你一起進(jìn)步。
如果喜歡這篇文章,記得點(diǎn)贊,轉(zhuǎn)發(fā)。讓你的好基友和你一樣優(yōu)秀。
歡迎關(guān)注 「FEHub」,每天進(jìn)步一點(diǎn)點(diǎn)
[1]
spark-md5: https://www.npmjs.com/package/spark-md5
[2]
file-upload: https://github.com/yeyan1996/file-upload
[3]
寫給新手前端的各種文件上傳攻略,從小圖片到大文件斷點(diǎn)續(xù)傳: https://juejin.im/post/5da14778f265da5bb628e590
[4]
Blob.slice: https://developer.mozilla.org/zh-CN/docs/Web/API/Blob/slice
言
本文較長(5000字左右),建議閱讀時(shí)間: 20min+
一個(gè)iOS App的穩(wěn)定性,主要決定于整體的系統(tǒng)架構(gòu)設(shè)計(jì),同時(shí)也不可忽略編程的細(xì)節(jié),正所謂“千里之堤,潰于蟻穴”,一旦考慮不周,看似無關(guān)緊要的代碼片段可能會帶來整體軟件系統(tǒng)的崩潰。尤其因?yàn)樘O果限制了熱更新機(jī)制,App本身的穩(wěn)定性及容錯性就顯的更加重要,之前可以通過發(fā)布熱補(bǔ)丁的方式解決線上代碼問題,現(xiàn)在就需要在提交之前對App開發(fā)周期內(nèi)的各個(gè)指標(biāo)進(jìn)行實(shí)時(shí)監(jiān)測,盡量讓問題暴漏在開發(fā)階段,然后及時(shí)修復(fù),減少線上出問題的幾率。針對一個(gè)App的開發(fā)周期,它的穩(wěn)定性指標(biāo)主要有以下幾個(gè)環(huán)節(jié)構(gòu)成,用一個(gè)腦圖表示如下:
穩(wěn)定性指標(biāo)
1 開發(fā)過程
開發(fā)過程中,主要是通過監(jiān)控內(nèi)存使用及泄露,CPU使用率,F(xiàn)PS,啟動時(shí)間等指標(biāo),以及常見的UI的主線程監(jiān)測,NSAssert斷言等,最好能在Debug模式下,實(shí)時(shí)顯示在界面上,針對出現(xiàn)的問題及早解決。
內(nèi)存問題
內(nèi)存問題主要包括兩個(gè)部分,一個(gè)是iOS中常見循環(huán)引用導(dǎo)致的內(nèi)存泄露 ,另外就是大量數(shù)據(jù)加載及使用導(dǎo)致的內(nèi)存警告。
mmap
雖然蘋果并沒有明確每個(gè)App在運(yùn)行期間可以使用的內(nèi)存最大值,但是有開發(fā)者進(jìn)行了實(shí)驗(yàn)和統(tǒng)計(jì),一般在占用系統(tǒng)內(nèi)存超過20%的時(shí)候會有內(nèi)存警告,而超過50%的時(shí)候,就很容易Crash了,所以內(nèi)存使用率還是盡量要少,對于數(shù)據(jù)量比較大的應(yīng)用,可以采用分步加載數(shù)據(jù)的方式,或者采用mmap方式。mmap 是使用邏輯內(nèi)存對磁盤文件進(jìn)行映射,中間只是進(jìn)行映射沒有任何拷貝操作,避免了寫文件的數(shù)據(jù)拷貝。 操作內(nèi)存就相當(dāng)于在操作文件,避免了內(nèi)核空間和用戶空間的頻繁切換。之前在開發(fā)輸入法的時(shí)候 ,詞庫的加載也是使用mmap方式,可以有效降低App的內(nèi)存占用率,具體使用可以參考鏈接第一篇文章。
循環(huán)引用
循環(huán)引用是iOS開發(fā)中經(jīng)常遇到的問題,尤其對于新手來說是個(gè)頭疼的問題。循環(huán)引用對App有潛在的危害,會使內(nèi)存消耗過高,性能變差和Crash等,iOS常見的內(nèi)存主要以下三種情況:
Delegate
代理協(xié)議是一個(gè)最典型的場景,需要你使用弱引用來避免循環(huán)引用。ARC時(shí)代,需要將代理聲明為weak是一個(gè)即好又安全的做法:
@property (nonatomic, weak) id delegate;
NSTimer
NSTimer我們開發(fā)中會用到很多,比如下面一段代碼
- (void)viewDidLoad {
這是典型的循環(huán)引用,因?yàn)閠imer會強(qiáng)引用self,而self又持有了timer,所有就造成了循環(huán)引用。那有人可能會說,我使用一個(gè)weak指針,比如
__weak typeof(self) weakSelf=self;
self.mytimer=[NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(doSomeThing) userInfo:nil repeats:YES];
但是其實(shí)并沒有用,因?yàn)椴还苁莣eakSelf還是strongSelf,最終在NSTimer內(nèi)部都會重新生成一個(gè)新的指針指向self,這是一個(gè)強(qiáng)引用的指針,結(jié)果就會導(dǎo)致循環(huán)引用。那怎么解決呢?主要有如下三種方式:
使用類方法
使用weakProxy
使用GCD timer
具體如何使用,我就不做具體的介紹,網(wǎng)上有很多可以參考。
Block
Block的循環(huán)引用,主要是發(fā)生在ViewController中持有了block,比如:
@property (nonatomic, copy) LFCallbackBlock callbackBlock;
同時(shí)在對callbackBlock進(jìn)行賦值的時(shí)候又調(diào)用了ViewController的方法,比如:
self.callbackBlock=^{
就會發(fā)生循環(huán)引用,因?yàn)椋篤iewController->強(qiáng)引用了callback->強(qiáng)引用了ViewController,解決方法也很簡單:
__weak __typeof(self) weakSelf=self;
原因是使用MRC管理內(nèi)存時(shí),Block的內(nèi)存管理需要區(qū)分是Global(全局)、Stack(棧)還是Heap(堆),而在使用了ARC之后,蘋果自動會將所有原本應(yīng)該放在棧中的Block全部放到堆中。全局的Block比較簡單,凡是沒有引用到Block作用域外面的參數(shù)的Block都會放到全局內(nèi)存塊中,在全局內(nèi)存塊的Block不用考慮內(nèi)存管理問題。(放在全局內(nèi)存塊是為了在之后再次調(diào)用該Block時(shí)能快速反應(yīng),當(dāng)然沒有調(diào)用外部參數(shù)的Block根本不會出現(xiàn)內(nèi)存管理問題)。
所以Block的內(nèi)存管理出現(xiàn)問題的,絕大部分都是在堆內(nèi)存中的Block出現(xiàn)了問題。默認(rèn)情況下,Block初始化都是在棧上的,但可能隨時(shí)被收回,通過將Block類型聲明為copy類型,這樣對Block賦值的時(shí)候,會進(jìn)行copy操作,copy到堆上,如果里面有對self的引用,則會有一個(gè)強(qiáng)引用的指針指向self,就會發(fā)生循環(huán)引用,如果采用weakSelf,內(nèi)部不會有強(qiáng)類型的指針,所以可以解決循環(huán)引用問題。
那是不是所有的block都會發(fā)生循環(huán)引用呢?其實(shí)不然,比如UIView的類方法Block動畫,NSArray等的類的遍歷方法,也都不會發(fā)生循環(huán)引用,因?yàn)楫?dāng)前控制器一般不會強(qiáng)引用一個(gè)類。
其他內(nèi)存問題
1 NSNotification addObserver之后,記得在dealloc里面添加remove;
2 動畫的repeat count無限大,而且也不主動停止動畫,基本就等于無限循環(huán)了;
3 forwardingTargetForSelector返回了self。
內(nèi)存解決思路:
1 通過Instruments來查看leaks
2 集成Facebook開源的FBRetainCycleDetector
3 集成MLeaksFinder
具體原理及使用,可以參考鏈接。
CPU使用率
CPU的使用也可以通過兩種方式來查看,一種是在調(diào)試的時(shí)候Xcode會有展示,具體詳細(xì)信息可以進(jìn)入Instruments內(nèi)查看,通過查看Instruments的time profile來定位并解決問題。另一種常見的方法是通過代碼讀取CPU使用率,然后顯示在App的調(diào)試面板上,可以在Debug環(huán)境下顯示信息,具體代碼如下:
int result;
FPS監(jiān)控
目前主要使用CADisplayLink來監(jiān)控FPS,CADisplayLink是一個(gè)能讓我們以和屏幕刷新率相同的頻率將內(nèi)容畫到屏幕上的定時(shí)器。我們在應(yīng)用中創(chuàng)建一個(gè)新的 CADisplayLink 對象,把它添加到一個(gè)runloop中,并給它提供一個(gè) target 和selector 在屏幕刷新的時(shí)候調(diào)用,需要注意的是添加到runloop的common mode里面,代碼如下:
- (void)setupDisplayLink {
啟動時(shí)間
點(diǎn)評App里面本身就包含了很多復(fù)雜的業(yè)務(wù),比如外賣、團(tuán)購、到綜和酒店等,同時(shí)還引入了很多第三方SDK比如微信、QQ、微博等,在App初始化的時(shí)候,很多SDK及業(yè)務(wù)也開始初始化,這就會拖慢應(yīng)用的啟動時(shí)間。
App的啟動時(shí)間t(App總啟動時(shí)間)=t1(main()之前的加載時(shí)間) + t2(main()之后的加載時(shí)間)。
針對t1的優(yōu)化,優(yōu)化主要有如下:
減少不必要的framework,因?yàn)閯討B(tài)鏈接比較耗時(shí);
檢查framework應(yīng)當(dāng)設(shè)為optional和required,如果該framework在當(dāng)前App支持的所有iOS系統(tǒng)版本都存在,那么就設(shè)為required,否則就設(shè)為optional,因?yàn)閛ptional會有些額外的檢查;
合并或者刪減一些OC類,這些我會在后續(xù)的靜態(tài)檢查中進(jìn)行詳解;
針對t2的時(shí)間優(yōu)化,可以采用:
異步初始化部分操作,比如網(wǎng)絡(luò),數(shù)據(jù)讀取;
采用延遲加載或者懶加載某些視圖,圖片等的初始化操作;
對與圖片展示類的App,可以將解碼的圖片保存到本地,下次啟動時(shí)直接加載解碼后的圖片;
對實(shí)現(xiàn)了+load()方法的類進(jìn)行分析,盡量將load里的代碼延后調(diào)用。
UI的主線程監(jiān)測
我們都知道iOS的UI的操作一定是在主線程進(jìn)行,該監(jiān)測可以通過hook UIView的如下三個(gè)方法
-setNeedsLayout,
確保它們都是在主線程執(zhí)行。子線程操作UI可能會引起什么問題,蘋果說得并不清楚,但是在實(shí)際開發(fā)中,我們經(jīng)常會遇到整個(gè)App的動畫丟失,很大原因就是UI操作不是在主線程導(dǎo)致。
2 靜態(tài)分析過程
靜態(tài)分析在這里,我主要介紹兩方面,一個(gè)是正常的code review機(jī)制,另外一個(gè)就是代碼靜態(tài)檢查工具
code review
組內(nèi)的code review機(jī)制,可以參考團(tuán)隊(duì)之前的OpenDoc - 前端團(tuán)隊(duì)CodeReview制度,iOS客戶端開發(fā),會在此基礎(chǔ)上進(jìn)行一些常見手誤及Crash情況的重點(diǎn)標(biāo)記,比如:
1 我們開發(fā)中首先都是在測試環(huán)境開發(fā),開發(fā)時(shí)可以將測試環(huán)境的url寫死到代碼中,但是在提交代碼的時(shí)候一定要將他改為線上環(huán)境的url,這個(gè)就可以通過gitlab中的重點(diǎn)比較部分字符串,給提交者一個(gè)強(qiáng)力的提示;
2 其他常見Crash的重點(diǎn)檢查,比如NSMutableString/NSMutableArray/NSMutableDictionary/NSMutableSet 等類下標(biāo)越界判斷保護(hù),或者 append/insert/add nil對象的保護(hù);
3 ARC下的release操作,UITableViewCell返回nil,以及前面介紹的常見的循環(huán)引用等。
code review機(jī)制,一方面是依賴寫代碼者的代碼習(xí)慣及質(zhì)量,另一名依賴審查者的經(jīng)驗(yàn)和細(xì)心程度,即使讓多人revew,也可能會漏過一些錯誤,所以我們又添加了代碼的靜態(tài)檢查。
代碼靜態(tài)檢查
代碼靜態(tài)分析(Static Program Analysis)是指在不運(yùn)行程序的條件下,由代碼靜態(tài)分析工具自動對程序進(jìn)行分析的方法. iOS常見的靜態(tài)掃描工具有Clang Static Analyzer、OCLint、Infer,這些主要是用來檢查可能存在的問題,還有Deploymate用來檢查api的兼容性。
Clang Static Analyzer
Clang Static Analyzer是一款靜態(tài)代碼掃描工具,專門用于針對C,C++和Objective-C的程序進(jìn)行分析。已經(jīng)被Xcode集成,可以直接使用Xcode進(jìn)行靜態(tài)代碼掃描分析,Clang默認(rèn)的配置主要是空指針檢測,類型轉(zhuǎn)換檢測,空判斷檢測,內(nèi)存泄漏檢測這種等問題。如果需要更多的配置,可以使用開源的Clang項(xiàng)目,然后集成到自己的CI上。
OCLint
OCLint是一個(gè)強(qiáng)大的靜態(tài)代碼分析工具,可以用來提高代碼質(zhì)量,查找潛在的bug,主要針對 C、C++和Objective-C的靜態(tài)分析。功能非常強(qiáng)大,而且是出自國人之手。OCLint基于 Clang 輸出的抽象語法樹對代碼進(jìn)行靜態(tài)分析,支持與現(xiàn)有的CI集成,部署之后基本不需要維護(hù),簡單方便。
OCLint可以發(fā)現(xiàn)這些問題
可能的bug - 空的 if / else / try / catch / finally 語句
未使用的代碼 - 未使用的局部變量和參數(shù)
復(fù)雜的代碼 - 高圈復(fù)雜度, NPath復(fù)雜, 高NCSS
冗余代碼 - 多余的if語句和無用的括號
壞味道的代碼 - 過長的方法和過長的參數(shù)列表
不好的使用 - 倒邏輯和入?yún)⒅匦沦x值
對于OCLint的與原理和部署方法,可以參考團(tuán)隊(duì)成員之前的文章:靜態(tài)代碼分析之OCLint的那些事兒,每次提交代碼后,可以在打包的過程中進(jìn)行代碼檢查,及早發(fā)現(xiàn)有問題的代碼。當(dāng)然也可以在合并代碼之前執(zhí)行對應(yīng)的檢查,如果檢查不通過,不能合并代碼,這樣檢查的力度更大。
Infer
Infer facebook開源的靜態(tài)分析工具,Infer可以分析 Objective-C, Java 或者 C 代碼,報(bào)告潛在的問題。Infer效率高,規(guī)模大,幾分鐘能掃描數(shù)千行代碼;
C/OC中捕捉的bug類型主要有:
1:Resource leak
只在 OC中捕捉的bug類型
1:Retain cycle
結(jié)論
Clang Static Analyzer和Xcode集成度更高、更好用,支持命令行形式,并且能夠用于持續(xù)集成。OCLint有更多的檢查規(guī)則和定制,和很多工具集成,也同樣可用于持續(xù)集成。Infer效率高,規(guī)模大,幾分鐘能掃描數(shù)千行代碼;支持增量及非增量分析;分解分析,整合輸出結(jié)果。infer能將代碼分解,小范圍分析后再將結(jié)果整合在一起,兼顧分析的深度和速度,所以根據(jù)自己的項(xiàng)目特點(diǎn),選擇合適的檢查工具對代碼進(jìn)行檢查,減少人力review成本,保證代碼質(zhì)量,最大限度的避免運(yùn)行錯誤。
3 測試過程
前面介紹了很多指標(biāo)的監(jiān)測,代碼靜態(tài)檢查,這些都是性能相關(guān)的,真正決定一個(gè)App功能穩(wěn)定是否的是測試環(huán)節(jié)。測試是發(fā)布之前的最后一道卡,如果bug不能在測試中發(fā)現(xiàn),那么最終就會觸達(dá)用戶,所以一個(gè)App的穩(wěn)定性,很大程度決定它的測試過程。iOS App的測試包括以下幾個(gè)層次:單元測試,UI測試,功能測試,異常測試。
單元測試
XCTest是蘋果官方提供的單元測試框架,與Xcode集成在一起,由此蘋果提供了很詳細(xì)的文檔XCTest。
Xcode單元測試包含在一個(gè)XCTestCase的子類中。依據(jù)約束,每一個(gè) XCTestCase 子類封裝一個(gè)特殊的有關(guān)聯(lián)的集合,例如一個(gè)功能、用例或者一個(gè)程序流。同時(shí)還提供了XCTestExpectation來處理異步任務(wù)的測試,以及性能測試measureBlock(),還包括很多第三方測試框架比如:KiWi,Quick,Specta等,以及常用的mock框架OCMock。
單元測試的目的是將程序中所有的源代碼,隔離成最小的可測試單元,以確保每個(gè)單元的正確性,如果每個(gè)單元都能保證正確,就能保證應(yīng)用程序整體相當(dāng)程度的正確性。但是在實(shí)際的操作過程中,很多公司都很難徹底執(zhí)行單元測試,主要就是單元測試代碼量甚至大于功能開發(fā),比較難于維護(hù)。
對于測試用例覆蓋度多少合適這個(gè)話題,也是仁者見仁智者見智,其實(shí)一個(gè)軟件覆蓋度在50%以上就可以稱為一個(gè)健壯的軟件了,要達(dá)到70,80這些已經(jīng)是非常難了,不過我們常見的一些第三方開源框架的測試用例覆蓋率還是非常高的,讓人咋舌。例如,AFNNetWorking的覆蓋率高達(dá)87%,SDWebImage的覆蓋率高達(dá)77%。
UI測試
Xcode7中新增了UI Test測試,UI測試是模擬用戶操作,進(jìn)而從業(yè)務(wù)處層面測試,常用第三方庫有KIF,appium。關(guān)于XCTest的UI測試,建議看看WWDC 2015的視頻UI Testing in Xcode。 UI測試還有一個(gè)核心功能是UI Recording。選中一個(gè)UI測試用例,然后點(diǎn)擊圖中的小紅點(diǎn)既可以開始UI Recoding。你會發(fā)現(xiàn):隨著點(diǎn)擊模擬器,自動合成了測試代碼。(通常自動合成代碼后,還需要手動的去調(diào)整)
UI測試
功能測試
功能測試跟上述的UT和UI測試有一些相通的地方,首先針對各個(gè)模塊設(shè)計(jì)的功能,測試是否達(dá)到產(chǎn)品的目的,通常功能測試主要是測試及產(chǎn)品人員,然后還需要進(jìn)行專項(xiàng)測試,比如我們公司的云測平臺,會對整個(gè)App的性能,穩(wěn)定性,UI等都進(jìn)行整體評測,看是否達(dá)到標(biāo)準(zhǔn),對于大規(guī)模的活動,還需要進(jìn)行服務(wù)端的壓力測試,確保整個(gè)功能無異常。測試通過后,可以進(jìn)行estFlight測試,到最后正式發(fā)布。
功能測試還包括如下場景:系統(tǒng)兼容性測試,屏幕分辨率兼容性測試,覆蓋安裝測試,UI是否符合設(shè)計(jì),消息推送等,以及前面開發(fā)過程中需要監(jiān)控的內(nèi)存、cpu、電量、網(wǎng)絡(luò)流量、冷啟動時(shí)間、熱啟動時(shí)間、存儲、安裝包的大小等測試。
異常測試
異常測試主要是針對一些不常規(guī)的操作
使用過程中的來電時(shí)及結(jié)束后,界面顯示是否正常;
狀態(tài)欄為兩倍高度時(shí),界面是否顯示正常;
意外斷電后,數(shù)據(jù)是否保存,數(shù)據(jù)是否有損害等;
設(shè)備充電時(shí),不同電量時(shí)的App響應(yīng)速度及操作流暢度等;
其他App的相互切換,前后臺轉(zhuǎn)換時(shí),是否正常;
網(wǎng)絡(luò)變化時(shí)的提示,弱網(wǎng)環(huán)境下的網(wǎng)絡(luò)請求成功率等;
各種monkey的隨機(jī)點(diǎn)擊,多點(diǎn)觸摸測試等是否正常;
更改系統(tǒng)時(shí)間,字體大小,語言等顯示是否正常;
設(shè)備存儲不夠時(shí),是否能正常操作;
...
異常測試有很多,App針對自身的特點(diǎn),可以選擇性的進(jìn)行邊界和異常測試,也是保證App穩(wěn)定行的一個(gè)重要方面。
4 發(fā)布及監(jiān)控
因?yàn)橐苿覣pp的特點(diǎn),即使我們通過了各種測試,產(chǎn)品最終發(fā)布后,還是會遇到很多問題,比如Crash,網(wǎng)絡(luò)失敗,數(shù)據(jù)損壞,賬號異常等等。針對已經(jīng)發(fā)布的App,主要有一下方式保證穩(wěn)定性:
熱修復(fù)
目前比較流行的熱修復(fù)方案都是基于JSPatch、React Native、Weex、lua+wax。
JSPatch能做到通過js調(diào)用和改寫OC方法。最根本的原因是 Objective-C 是動態(tài)語言,OC上所有方法的調(diào)用/類的生成都通過 objective-c Runtime 在運(yùn)行時(shí)進(jìn)行,我們可以通過類名和方法名反射得到相應(yīng)的類和方法,也可以替換某個(gè)類的方法為新的實(shí)現(xiàn),還可以新注冊一個(gè)類,為類添加方法。JSPatch 的原理就是:JS傳遞字符串給OC,OC通過 Runtime 接口調(diào)用和替換OC方法。
React Native 是從 Web 前端開發(fā)框架 React 延伸出來的解決方案,主要解決的問題是 Web 頁面在移動端性能低的問題,React Native 讓開發(fā)者可以像開發(fā) Web 頁面那樣用 React 的方式開發(fā)功能,同時(shí)框架會通過 JavaScript 與 Objective-C 的通信讓界面使用原生組件渲染,讓開發(fā)出來的功能擁有原生App的性能和體驗(yàn)。
Weex阿里開源的,基于Vue+Native的開發(fā)模式,跟RN的主要區(qū)別就在React和Vue的區(qū)別,同時(shí)在RN的基礎(chǔ)上進(jìn)行了部分性能優(yōu)化,總體開發(fā)思路跟RN是比較像的。
但是在今年上半年,蘋果以安全為理由,開始拒絕有熱修復(fù)功能的應(yīng)用,但其實(shí)蘋果拒的不是熱更新,拒的是從網(wǎng)絡(luò)下載代碼并修改應(yīng)用行為,蘋果禁止的是“基于反射的熱更新“,而不是 “基于沙盒接口的熱更新”。而大部分框架(如 React Native、weex)和游戲引擎(比如 Unity、Cocos2d-x等)都屬于后者,所以不在被警告范圍內(nèi)。而JSPatch因?yàn)樵趪鴥?nèi)大部分應(yīng)用來做熱更新修復(fù)bug的行為,所以才回被蘋果禁止。
降級
用戶使用App一段時(shí)間后,可能會遇到這樣的情況:每次打開App時(shí)閃退,或者正常操作到某個(gè)界面時(shí)閃退,無法正常使用App。這樣的用戶體驗(yàn)十分糟糕,如果沒有一個(gè)好的解決方案,很容易被用戶刪除App,導(dǎo)致用戶量的流失。因?yàn)闊岣禄静荒苁褂茫蔷椭荒苁茿pp自身修復(fù)能力。目前常用的修復(fù)能力有:
啟動Crash的監(jiān)控及修復(fù)
1 在應(yīng)用起來的時(shí)候,記錄flag并保存本地,啟動一個(gè)定時(shí)器,比如5秒鐘內(nèi),如果沒有發(fā)生Crash,則認(rèn)為用戶操作正常,清空本地flag。
2 下次啟動,發(fā)現(xiàn)有flag,則表明上次啟動Crash,如果flag數(shù)組越大,則說明Crash的次數(shù)越多,這樣就需要對整個(gè)App進(jìn)行降級處理,比如登出賬號,清空Documents/Library/Caches目錄下的文件。
具體業(yè)務(wù)下的Crash及修復(fù)
針對某些具體業(yè)務(wù)Crash場景,如果是上線的前端頁面引起的,可以先對前端功能進(jìn)行回滾,或者隱藏入口,等修復(fù)完畢后再上線,如果是客戶端的某些異常,比如數(shù)據(jù)庫升遷問題,主要是進(jìn)行業(yè)務(wù)數(shù)據(jù)庫修復(fù),緩存文件的刪除,賬號退出等操作,盡量只修復(fù)此業(yè)務(wù)的相關(guān)的數(shù)據(jù)。
網(wǎng)絡(luò)降級
比如點(diǎn)評App,本身有CIP(公司內(nèi)部自己研發(fā)的)長連接,接入騰訊云的WNS長連接,UDP連接,HTTP短連接,如果CIP服務(wù)器發(fā)生問題,可以及時(shí)切換到WNS連接,或者降級到Http連接,保證網(wǎng)絡(luò)連接的成功率。
線上監(jiān)控
Crash監(jiān)控
Crash是對用戶來說是最糟糕的體驗(yàn),Crash日志能夠記錄用戶閃退的崩潰日志及堆棧,進(jìn)程線程信息,版本號,系統(tǒng)版本號,系統(tǒng)機(jī)型等有用信息,收集的信息越詳細(xì),越能夠幫助解決崩潰,所以各大App都有自己崩潰日志收集系統(tǒng),或者也可以使用開源或者付費(fèi)的第三方Crash收集平臺。
端到端成功率監(jiān)控
端到端監(jiān)控是從客戶端App發(fā)出請求時(shí)計(jì)時(shí),到App收到數(shù)據(jù)數(shù)據(jù)的成功率,統(tǒng)計(jì)對象是:網(wǎng)絡(luò)接口請求(包括H5頁面加載)的成敗和端到端延時(shí)情況。端到端監(jiān)控SDK提供了監(jiān)控上傳接口,調(diào)用SDK提供的監(jiān)控API可以將數(shù)據(jù)上報(bào)到監(jiān)控服務(wù)器中。
整個(gè)端到端監(jiān)控的可以在多個(gè)維度上做查詢端到端成功率、響應(yīng)時(shí)間、訪問量的查詢,維度包括:返回碼、網(wǎng)絡(luò)、版本、平臺、地區(qū)、運(yùn)營商等。
用戶行為日志
用戶行為日志,主要記錄用戶在使用App過程中,點(diǎn)擊元素的時(shí)間點(diǎn),瀏覽時(shí)長,跳轉(zhuǎn)流程等,然后基于此進(jìn)行用戶行為分析,大部分應(yīng)用的推薦算法都是基于用戶行為日志來統(tǒng)計(jì)的。某些情況下,Crash分析需要查詢用戶的行為日志,獲取用戶使用App的流程,幫助解決Crash等其他問題。
代碼級日志
代碼級別的日志,主要用來記錄一個(gè)App的性能相關(guān)的數(shù)據(jù),比如頁面打開速度,內(nèi)存使用率,CPU占用率,頁面的幀率,網(wǎng)絡(luò)流量,請求錯誤統(tǒng)計(jì)等,通過收集相關(guān)的上下文信息,優(yōu)化App性能。
總結(jié)
雖然現(xiàn)在市面上第三方平臺已經(jīng)很成熟,但是各大互聯(lián)公司都會自己開發(fā)線上監(jiān)控系統(tǒng),這樣保證數(shù)據(jù)安全,同時(shí)更加靈活。因?yàn)橐苿佑脩舻奶攸c(diǎn),在開發(fā)測試過程中,很難完全覆蓋所有用戶的全部場景,有些問題也只會在特定環(huán)境下才發(fā)生,所以通過線上監(jiān)控平臺,通過日志回?fù)频葯C(jī)制,及時(shí)獲取特定場景的上下文環(huán)境,結(jié)合數(shù)據(jù)分析,能夠及時(shí)發(fā)現(xiàn)問題,并后續(xù)修復(fù),提高App的穩(wěn)定性。
全文總結(jié)
本文主要從開發(fā)測試發(fā)布等流程來介紹了一個(gè)App穩(wěn)定性指標(biāo)及監(jiān)測方法,開發(fā)階段主要針對一些比較具體的指標(biāo),靜態(tài)檢查主要是掃描代碼潛在問題,然后通過測試保證App功能的穩(wěn)定性,線上降級主要是在盡量不發(fā)版的情況下,進(jìn)行自修復(fù),配合線上監(jiān)控,信息收集,用戶行為記錄,方便后續(xù)問題修復(fù)及優(yōu)化。本文觀點(diǎn)是作者從事iOS開發(fā)的一些經(jīng)驗(yàn),希望能對你有所幫助,觀點(diǎn)不同歡迎討論。
*請認(rèn)真填寫需求信息,我們會在24小時(shí)內(nèi)與您取得聯(lián)系。