提交規范
AngularJS 在開發者文檔中關于 git commit 的指導說明,提到嚴格的 git commit 格式規范可以在瀏覽項目歷史的過程中看到更易讀的信息,并且能用 git commit 的信息直接生成 AngularJS 的 change log 。
commit messages 格式規范
commit messages 由 header 、body 、footer 組成。
header 又包含 type 、scope 、subject 。header 是必需的,不過其中的 scope 是可選的。
body 和 footer 可以省略。
<type>(<scope>): <subject>
// 空行
<BLANK LINE>
<body>
// 空行
<BLANK LINE>
<footer>
注:為了能在 github 以及各種 git 工具中看得更清晰,commit messages 的每一行都不要超過 100 個字符。
Header
Type
類型必須是以下幾種之一:
feat: 新功能
fix: bug 修復
docs: 僅修改文檔
style: 修改格式(空格,格式化,省略分號等),對代碼運行沒有影響
refactor: 重構(既不是修 bug ,也不是加功能)
build: 構建流程、外部依賴變更,比如升級 npm 包、修改 webpack 配置等
perf: 性能優化
test: 測試相關
chore: 對構建過程或輔助工具和庫(如文檔生成)的更改
ci: ci 相關的更改
除此之外,還有一個特殊的類型 revert ,如果當前提交是為了撤銷之前的某次提交,應該用 revert 開頭,后面加上被撤銷的提交的 header,在 body 中應該注明: This reverts commit <hash>. ,hash 指的就是將要被撤銷的 commit SHA 。
// 例如
revert: feat(user): add user type
This reverts commit ca16a365467e17915f0273392f4a13331b17617d.
Scope
scope 可以指定提交更改的影響范圍,這個視項目而定,當修改影響超過單個的 scope 時,可以指定為 * 。
Sbuject
subject 是指更改的簡潔描述,長度約定在 50 個字符以內,通常遵循以下幾個規范:
用動詞開頭,第一人稱現在時表述,例如:change 代替 changed 或 changes
第一個字母小寫
結尾不加句號(.)
Body
body 部分是對本地 commit 的詳細描述,可以分成多行。
跟 subject 類似,用動詞開頭,第一人稱現在時表述,例如:change 代替 changed 或 changes。
body 應該說明修改的原因和更改前后的行為對比。
Footer
footer 基本用在這兩種情況:
不兼容的改動( Breaking Changes ),通常用 BREAKING CHANGE: 開頭,后面跟一個空格或兩個換行符。剩余的部分就是用來說明這個變動的信息和遷移方法等。
關閉 Issue, github 關閉 Issue 的例子
// BREAKING CHANGE: 的例子
BREAKING CHANGE: isolate scope bindings definition has changed and
the inject option for the directive controller injection was removed.
To migrate the code follow the example below:
Before:
scope: {
myAttr: 'attribute',
myBind: 'bind',
myExpression: 'expression',
myEval: 'evaluate',
myAccessor: 'accessor'
}
After:
scope: {
myAttr: '@',
myBind: '@',
myExpression: '&',
// myEval - usually not useful, but in cases where the expression is assignable, you can use '='
myAccessor: '=' // in directive's template change myAccessor() to myAccessor
}
The removed `inject` wasn't generaly useful for directives so there should be no code using it.
// Closes Issue 例子
Closes #2314, #3421
完整的例子
例一: feat
feat($browser): onUrlChange event (popstate/hashchange/polling)
Added new event to $browser:
- forward popstate event if available
- forward hashchange event if popstate not available
- do polling when neither popstate nor hashchange available
Breaks $browser.onHashChange, which was removed (use onUrlChange instead)
例二: fix
fix($compile): couple of unit tests for IE9
Older IEs serialize html uppercased, but IE9 does not...
Would be better to expect case insensitive, unfortunately jasmine does
not allow to user regexps for throw expectations.
Closes #392
Breaks foo.bar api, foo.baz should be used instead
例三: style
style($location): add couple of missing semi colons
查看更多例子
規范 commit message 的好處
首行就是簡潔實用的關鍵信息,方便在 git history 中快速瀏覽
具有詳實的 body 和 footer ,可以清晰的看出某次提交的目的和影響
可以通過 type 過濾出想要查找的信息,也可以通過關鍵字快速查找相關提交
可以直接從 commit 生成 change log
// 列舉幾個常用的 log 參數
// 輸出 log 的首行
git log --pretty=oneline
// 只輸出首行的 commit 信息。不包含 hash 和 合并信息等
git log --pretty=format:%s
// 查找有關“更新菜單配置項”的提交
git log --grep="更新菜單配置項"
// 打印出 chenfangxu 的提交
git log --author=chenfangxu
// 紅色的短 hash,黃色的 ref , 綠色的相對時間
git log --graph --pretty=format:'%Cred%h%Creset -%C(yellow)%d%Creset %s %Cgreen(%cr)%Creset'
用工具實現規范提交
上面介紹了規范提交的格式,如果讓各位同學在 git commit 的時候嚴格按照上面的規范來寫,首先心智是有負擔的,得記住不同的類型到底是用來定義什么的,subject 怎么寫,body 怎么寫,footer 要不要寫。其次,對人的規范大部分都是反人性的,所以很可能在過不了多久,就會有同學漸漸的不按照規范來寫??恳庵玖砜刂谱约簢栏癜凑找幏秮韺懯切枰~外耗費一些精力的,把精力耗費在這種事情上面實在有些浪費。
用工具實現規范提交的方案,一種是在提交的時候就提示必填字段,另一種是在提交后校驗字段是否符合規范。這兩種在實際項目中都是很有必要的。
Commitizen
Zen-like commit messages for internet citizens. 嗯~~一種禪意
Commitizen 是一個幫助撰寫規范 commit message 的工具。他有一個命令行工具 cz-cli,接下來會把使用 Commitizen 分成幾個階段來介紹。
體驗 git cz
// 全局安裝 Commitizen
npm install -g commitizen
你的倉庫可能還不是對 Commitizen 友好的,此時運行 git cz 的效果跟 git commit 一樣,也就是沒有效果。 不過,可以執行 npx git-cz 來體驗。
如果想直接運行 git cz 實現語義化的提交,可以根據 streamich/git-cz 文檔中說的全局安裝 git cz。
// 全局安裝 git cz
npm install -g git-cz
除此之外還有一種更推薦的方式,就是讓你的倉庫對 Commitizen 友好。
Commitizen 友好
全局安裝 Commitizen 后,用 cz-conventional-changelog 適配器來初始化你的項目
// 初始化 cz-conventional-changelog 適配器
commitizen init cz-conventional-changelog --save-dev --save-exact
上面的初始化做了三件事:
安裝 cz-conventional-changelog 依賴
把依賴保存到 package.json 的 dependencies 或 devDependencies 中
在根目錄的 package.json 中 添加如下所示的 config.commitizen
"config": {
"commitizen": {
"path": "./node_modules/cz-conventional-changelog"
}
}
或者,在項目根目錄下新建一個 .czrc 文件,內容設置為
{
"path": "cz-conventional-changelog"
}
現在運行 git cz 效果如下:
cz-customizable 自定義中文配置
通過上面的截圖可以看到,提交的配置選項都是英文的,如果想改成中文的,可以使用 cz-customizable 適配器。
運行下面的命令,注意之前已經初始化過一次了,這次再初始化,需要加 --force 覆蓋
npm install cz-customizable --save-dev
commitizen init cz-customizable --save-dev --save-exact --force
現在 package.json 中 config.commitizen 字段為:
"config": {
"commitizen": {
"path": "./node_modules/cz-customizable"
}
}
cz-customizable 文檔中說明了查找配置文件的方式有三種,我們按照第一種,在項目根目錄創建一個 .cz-config.js 的文件。按照給出的示例 cz-config-EXAMPLE.js 編寫我們的 config。 commit-type 可以參考 conventional-commit-types 。
可以點擊查看我配置好的文件 qiqihaobenben/commitizen-git/.cz-config.js ,里面中詳細的注釋。
commitlint 校驗提交
Commitizen 文檔中開始就介紹到,Commitizen 可以在觸發 git commit 鉤子之前就能給出提示,但是也明確表示提交時對 commit messages 的校驗也是很有用的。畢竟即使用了 Commitzen,也是能繞過去,所以提交最后的校驗很重要。
commitlint 可以檢查 commit messages 是否符合常規提交格式,需要一份校驗配置,推薦 @commitlint/config-conventional 。
npm i --save-dev @commitlint/config-conventional @commitlint/cli
在項目根目錄創建 commitlint.config.js 文件并設置校驗規則:
module.exports = {
extends: ["@commitlint/config-conventional"],
// rules 里面可以設置一些自定義的校驗規則
rules: {},
};
在項目中安裝 husky ,并在項目根目錄新建 husky.config.js 文件,加入以下設置:
// 安裝 husky
npm install --save-dev husky
// husky.config.js 中加入以下代碼
module.exports = {
"hooks": {
"commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
}
}
注意:因為 @commitlint/config-conventional 校驗規則遵循 Angular 的規范, 所以我們在用 cz-customizable 自定義中文配置時, 是按照給出的符合 Angular 規范的示例 cz-config-EXAMPLE.js 編寫.cz-config.js 的。但是如果你自定義的 Commitizen 配置不符合 Angular 規范,可以使用 commitlint-config-cz 設置校驗規則。(推薦還是按照 Angular 規范進行 cz-customizable 自定義配置)
// 安裝 commitlint-config-cz
npm install commitlint-config-cz --save-dev
// commitlint.config.js 改為
module.exports = {
extends: [
'cz'
]
};
git commit 觸發 git cz
在提交的時候,我們都習慣了 git commit ,雖然換成 git cz 不難,但是如果讓開發者在 git commit 時無感知的觸發 git cz 肯定是更好的,
而且也能避免不熟悉項目的人直接 git commit 提交一些不符合規范的信息。
我們可以在 husky.config.js 中設置:
"hooks": {
"prepare-commit-msg": "exec < /dev/tty && git cz --hook || true",
}
注意: 在 window 系統,可能需要在 git base 中才能生效。
生成 CHANGELOG
standard-version
是一個使用 semver 和 conventional-commits 支持生成 CHANGELOG 進行版本控制的實用程序。
standard-version 不只是能生成 CHANGELOG , 還能根據 commit 的 type 來進行版本控制。
// 安裝 standard-verison
npm i --save-dev standard-version
// 在 package.json 中的 scripts 加入 standard-version
{
"scripts": {
"release": "standard-version"
}
}
示例項目
可以查看 commitizen-git ,里面歸納了快速配置 Commitizen 友好倉庫的步驟。
差不多三五分鐘就能搞定。
可以看一下配置完后,執行 git commit 的效果。
擴展
更復雜的自定義提示
cz-customizable 中自定義配置項通常情況是夠用的,
commitlint 中校驗的規則基本上也是夠用的,但是會有比較硬核的開發者會覺得還是不夠,還要更多。比如一些 prompt 更加自定義,
提交時詢問的 question 添加更多的邏輯,比如可以把一些重要的字段校驗提前到 Commitizen 中,或者添加更多自定義的校驗。
如果真想這么干,那就去 fork 一份 cz-conventional-changelog 或者 cz-customizable 來改,
或者直接自己寫一個 adapter。
Commitizen 友好徽章
如果把倉庫配置成了對 Commitizen 友好的話,可以在 README.md 中加上這個小徽章
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
Web 安全是互聯網中不可或缺的一個領域,這個領域中誕生了大量的黑帽子與白帽子,他們都是安全領域的王者,在平時里,他們利用各種巧妙的技術互相博弈,時不時就會掀起一場 Web 安全浪潮,真可謂神仙打架,各顯神通。
本文從一個吃瓜群眾的角度,聊一聊 Web 安全的一些有趣故事。
安全世界觀
安全攻防案例
總結與思考
安全世界觀
在互聯網發展之初,IE 瀏覽器壟斷的時期,大家上網的目的都很單純,主要通過瀏覽器分享信息,獲取新聞。但隨著互聯網的不斷發展發展,一個網頁能做的事情越來越多,除了看新聞,我們還可以看視頻、玩游戲、購物、聊天等,這些功能都大大豐富了我們的生活。
隨著網頁功能的逐漸增多,就開始出現了一些黑帽子,他們試圖通過一些技術手段來牟取利益。在我小的時候,印象最深的就是木馬病毒,它可以監控你的鍵盤,將你在鍵盤上敲打的內容發送到黑客的機器上,黑客通過分析這些內容,很容易就能得到你的游戲賬號和密碼。
在這之后,就誕生出了一些殺毒軟件,致力于解決網絡上的各種病毒,隨著不斷地發展,殺毒軟件已經成為一臺電腦必不可少的軟件。
為什么會出現這樣的安全問題?
安全歸根到底是信任的問題,如果所有人都按照正常的流程去上網,不去謀取私利,也就沒有安全問題可談了。
安全的根本在于信任,但要讓所有人互相信任談何容易。在當前階段,我們可以做到:持續做好安全防護,讓漏洞越來越少,非法攻擊越來越困難,這樣就能逐漸減少黑帽子的數量,讓病毒制造者越來越少。
如何做好安全
要做好安全,首先得理解安全問題的屬性,前人通過無數實踐,最后將安全的屬性總結為安全三要素,分別為:機密性、完整性、可用性。
機密性
保護數據內容不被泄露。
通常使用加密的方法。
完整性
保護數據內容是完整的、沒有被篡改。
通常使用數字簽名的方法。
可用性
數據隨時都能夠使用。
通常是在防御 DOS。
有了安全 3 要素之后,我們就可以對安全問題進行評估了。
資產等級劃分
找出最重要的數據。
找出最重要數據的宿主空間,如:在數據庫里,那么數據庫就得重點防御。
找出數據庫的宿主空間,如:在一臺服務器上,那么這臺服務器就得做次等防御。
找出服務器的宿主空間,如:在 OSI 網絡層級上,那么在網絡層面就得做一般防御。
威脅分析
找出威脅(可能造成危害的來源)。
找出風險(可能出現的損失叫做風險)。
風險分析
采取多標準決策分析,即:風險 = 威脅等級 * 威脅可行性。
計算所有的威脅,將最終的風險進行排序,優先解決風險大的問題。
確認解決方案
找出不安全的實現方式,并確定解決方案。
解決方案不要改變商業需求的初衷。
解決方案需對用戶透明,不要改變用戶的習慣。
做好安全評估之后,我們就有了一份安全解決方案,后續的安全工作只需按照這個方案去做,就沒有任何問題。
安全的原則
有了安全解決方案之后,我們還可以制定一些安全原則,遵守原則做事,可以讓我們事半功倍。
黑名單、白名單原則
白名單方案指的是給安全的資源授權。
黑名單方案指的是禁用不安全的資源。
我們應該優先使用白名單方案,因為黑名單通常統計不完所有的不安全資源。
如:XSS 攻擊的方式非常多,可以通過 script、css、image 標簽等,盡管你將這些標簽都加入黑名單,也不能保證其他的標簽都沒有 XSS 的攻擊隱患。
最小權限原則
只授予必要的權限,不要過度授權,減少出錯機會。
如:普通權限的 Linux 用戶只能操作 ~ 文件夾下的目錄,如果有人想刪庫跑路,在執行 rm -rf / 時,就會提示無權限。
縱深防御原則
這條原則類似 木桶理論,安全水平往往取決于最短的那塊板。
即:不要留下短板,黑帽子們往往可以利用短板為突破口,挖掘更大的漏洞。
數據與代碼分離原則
當用戶數據被當成代碼執行時,混淆了數據和代碼的邊界,從而導致安全問題。
如:XSS 就是利用這一點去攻擊的。
不可預測性原則
這條原則是為了提高攻擊門檻,有效防止基于篡改、偽造的攻擊。
如:數據庫中使用 uuid 代替 number 型的自增主鍵,可以避免 id 被攻擊者猜到,從而進行批量操作。
token 也是利用不可預測性,攻擊者無法構造 token 也就無法進行攻擊。
有了這些安全原則,我們就可以開干了,接下來介紹幾個常見的攻防案例。
安全攻防案例
安全攻防的案例非常多,這里主要介紹幾個出鏡率比較高的安全問題。
客戶端攻擊
XSS 攻擊
CSRF 攻擊
點擊劫持
XSS 攻擊
XSS 攻擊的本質是將用戶數據當成了 HTML 代碼一部分來執行,從而混淆原本的語義,產生新的語義。
如圖所示,我們注冊了一個 <script>alert(document.cookie)</script> 的用戶名,所有能看到此用戶名字的頁面,都會彈出當前瀏覽器的 Cookie,如果代碼的邏輯是將 Cookie 發送到攻擊者的網站,攻擊者就能冒充當前用戶進行登錄了。
XSS 攻擊方式有很多,所有和用戶交互的地方,都有可能存在 XSS 攻擊。
例如:
所有 input 框。
window.location。
window.name。
document.referrer。
document.cookie。
localstorage。
...
由于頁面中與用戶交互的地方非常多,肯定還有一些 XSS 的攻擊方式沒有被發現,而一旦被黑帽子發現,就可能造成嚴重的影響,所以我們務必引起重視。
XSS 攻擊影響
被 XSS 攻擊成功后,攻擊者就可以獲取大量的用戶信息,例如:
識別用戶 UA。
識別用戶瀏覽器擴展。
識別用戶瀏覽過的網站。
通過 CSS 的 Visited 屬性。
獲取用戶真實的 IP。
通過 WebRTC 等。
盜取 Cookie
偽造用戶登錄,竊取用戶資料。
XSS 釣魚。
向頁面注入一個登錄彈窗,讓用戶認為是網站內的登錄彈窗(其實是釣魚網站的),一旦用戶登錄,賬號密碼就泄露給了釣魚網站。
XSS 攻擊防御
目前來說,XSS 已經得到了互聯網行業的重視,許多開發框架都內置了安全的 HTML 渲染方法。
我們也可以自定義進行一些安全配置。
配置 HTTP 中的 http-only 頭,讓前端 JS 不能操作 Cookie。
輸入檢查,在用戶提交數據時,使用 XssFilter 過濾掉不安全的數據。
輸出檢查,在頁面渲染的時候,過濾掉危險的數據。
CSRF 攻擊
CSRF(Cross-site request forgery)跨站請求偽造,是一種利用用戶身份,執行一些用戶非本意的操作。
如圖所示:
用戶先登錄了服務器 B,然后去訪問服務器 C。
服務器 C 通過惡意腳本,冒充 A 去調用服務器 B 上的某個功能,
對于服務器 B 來說,還以為這是 A 發起的請求,就當作正常請求處理了。
試想一下,如果 C 冒充 A 進行了一次轉賬,必定會造成大量的經濟損失。
CSRF 防御方式
防御 CSRF 主要有以下幾種方式:
驗證碼
每一次請求都要求用戶驗證,以確保請求真實可靠。
即:利用惡意腳本不能識別復雜的驗證碼的特點,保證每次請求都是合法的。
Referer 檢查
檢查發起請求的服務器,是否為目標服務器。
即:HTTP 請求中的 Referer 頭傳遞了當前請求的域名,如果此域名是非法服務器的域名,則需要禁止訪問。
Token
利用不可預測性原則,每一請求必須帶上一段隨機碼,這段隨機碼由正常用戶保存,黑帽子不知道隨機碼,也就無法冒充用戶進行請求了。
點擊劫持
點擊劫持是一種視覺欺騙的攻擊手段。攻擊者將需要攻擊的網站通過 iframe 嵌套的方式嵌入自己的網頁中,并將 iframe 設置為透明,在頁面中透出一個按鈕誘導用戶點擊。
就像一張圖片上面鋪了一層透明的紙一樣,你看到的是攻擊者的頁面,但是其實這個頁面只是在底部,而你真正點擊的是被攻擊者透明化的另一個網頁。
如果所示,當你點擊了頁面上的按鈕之后,本以為會...... ,而真正執行的操作是關注了某人的博客。
點擊劫持防御
由于點擊劫持主要通過 iframe,所以在防御時,主要基于 iframe 去做。
方案一:frame busting
正常網站使用 JS 腳本判斷是否被惡意網站嵌入,如:博客網站監測到被一個 iframe 打開,自動跳轉到正常的頁面即可。
if (self !== top) { // 跳回原頁面 top.location = self.location;}
方案二:使用 HTTP 中的 x-frame-options 頭,控制 iframe 的加載,它有 3 個值可選:
DENY,表示頁面不允許通過 iframe 的方式展示。
SAMEORIGIN,表示頁面可以在相同域名下通過 iframe 的方式展示。
ALLOW-FROM,表示頁面可以在指定來源的 iframe 中展示。
配置 iframe 的 sandbox 屬性
sandbox = "allow-same-origin" 則只能加載與主站同域的資源。
服務器端攻擊
服務器端的攻擊的方式也非常多,這里列舉幾個常見的。
SQL 注入攻擊
文件上傳漏洞
登錄認證攻擊
應用層拒絕服務攻擊
webServer 配置安全
SQL 注入攻擊
SQL 注入和 XSS 一樣,都是違背了數據和代碼分離原則導致的攻擊方式。
如圖所示,我們利用 SQL 注入,就能在不需要密碼的情況下,直接登錄管理員的賬號。
攻擊的前提是:后端只用了簡單的拼接 SQL 的方式去查詢數據。
# 拼接出來的 sql 如下:select * from user where username = 'admin' or 1=1 and password = 'xxx'# 無論密碼輸入什么,這條 sql 語句都能查詢到管理員的信息
除此之外,SQL 注入還有以下幾種方式:
使用 SQL 探測,猜數據庫表名,列名。
通過 MySQL 內置的 benchmark 探測數據庫字段。
如:一段偽代碼 select database as current if current[0]==='a',benchmark(10000,'猜對了') 如果表明猜對了,就延遲 10 s 并返回成功。
使用存儲過程執行系統命令
通過內置的方法或存儲過程執行 shell 腳本。
如:xp_cmdshell、sys_eval、sys_exec 等。
字符串截斷
如:MySQL 在處理超長的字符串時,會顯示警告,但會執行成功。
注冊一個 admin + 50 個空格的用戶,會觸發截斷,最終新增一個 admin 用戶,這樣就能擁有管理員權限了。
SQL 注入防御
防止 SQL 注入的最好的辦法就是,不要手動拼接 SQL 語句。
最佳方案,使用預編譯語句綁定變量
通常是指框架提供的拼接 SQL 變量的方法。
這樣的語義不會發生改變,變量始終被當成變量。
嚴格限制數據類型,如果注入了其他類型的數據,直接報錯,不允許執行。
使用安全的存儲過程和系統函數。
CRLF 注入
在注入攻擊中,換行符注入也是非常常見的一種攻擊方式。
如果在 HTTP 請求頭中注入 2 個換行符,會導致換行符后面的所有內容都被解析成請求實體部分。
攻擊者通常在 Set-Cookie 時,注入換行符,控制請求傳遞的內容。
文件上傳漏洞
上傳文件是網頁開發中的一個常見功能,如果不加處理,很容易就會造成攻擊。
如圖所示,攻擊者上傳了一個木馬文件,并且通過返回的 URL 進行訪問,就能控制服務器。
通常我們會控制上傳文件的后綴名,但也不能完全解決問題,攻擊者還可以通過以下方式進行攻擊:
偽造正常文件
將木馬文件偽裝成正常的后綴名進行上傳。
如果要避免這個問題,我們可以繼續判斷上傳文件的文件頭前 10 個字節。
Apache 解析方式是從后往前解析,直到找到一個認識的后綴名為止
如:上傳一個 abc.php.rar.rar.rar 能繞過后綴名檢查,但在執行時,被當成一個 php 文件進行執行。
IIS 會截斷分號進行解析
如:abc.asp;xx.png 能繞過后綴名檢查,但在執行時,被當成一個 asp 文件進行執行。
HTTP PUT 方法允許將文件上傳到指定位置
通過 HTTP MOVE 方法,還能修改上傳的文件名。
通過二者配合,就能先上傳一個正常的后綴名,然后改為一個惡意的后綴名。
PHP CGI 路徑問題
執行 http://abc.com/test.png/xxx.php 時,會把 test.png 當做 php 文件去解析。
如果用戶正好是把一段惡意的 php 腳本當做一張圖片進行上傳,就會觸發這個攻擊。
文件上傳漏洞防御
防御文件上傳漏洞,可以從以下幾點考慮:
將文件上傳的目錄設置為不可執行。
判斷文件類型
檢查 MIME Type,配置白名單。
檢查后綴名,配置白名單。
使用隨機數改寫文件名和文件路徑
上傳文件后,隨機修改文件名,讓攻擊者無法執行攻擊。
單獨設置文件服務器的域名
單獨做一個文件服務器,并使用單獨的域名,利用同源策略,規避客戶端攻擊。
通常做法是將靜態資源存放在 CDN 上。
登錄認證攻擊
登錄認證攻擊可以理解為一種破解登錄的方法。攻擊者通常采用以下幾種方式進行破解:
彩虹表
攻擊者通過搜集大量明文和 MD5 的對應關系,用于破解 MD5 密文找出原文。
對于彩虹表中的 MD5 密碼,我們可以加鹽,進行二次加密,避免被破解。
Session Fixation 攻擊
利用應用系統在服務器的 SessionID 固定不變機制,借助他人用相同的 SessionID 獲取認證和授權。
攻擊者登錄失敗后,后端返回了 SessionID,攻擊者將 SessionID 交給正常用戶去登錄,登錄成功后,攻擊者就能使用這個 SessionID 冒充正常用戶登錄了。
如果瀏覽器每一次登錄都刷新 SessionID 可以避免這個問題。
Session 保持攻擊
有些時候,后端出于用戶體驗考慮,只要這個用戶還活著,就不會讓這個用戶的 Session 失效。
攻擊者可以通過不停發起請求,可以讓這個 Session 一直活下去。
登錄認證防御方式
多因素認證
密碼作為第一道防御,但在密碼驗證成功后,我們還可以繼續驗證:動態口令,數字證書,短信驗證碼等,以保證用戶安全。
由于短信和網頁完全是 2 套獨立的系統,攻擊者很難獲取到短信驗證碼,也就無法進行攻擊。
除此之外,前端登錄認證還有多種方式,如果你對此感興趣,可以參考我之前寫的 前端登錄,這一篇就夠了。
應用層拒絕服務攻擊
應用層拒絕服務攻擊,又叫 DDOS 攻擊,它指的是利用大量的請求造成資源過載,導致服務器不可用。
通常有以下幾種 DDOS 攻擊方式:
SYN Flood 洪水攻擊
利用 HTTP 3 次握手機制,消耗服務器連接資源。
如:攻擊者發起大量的 HTTP 請求,但并不完成 3 次握手,而是只握手 2 次,這時服務器端會繼續等待直至超時。這時的服務器會一直忙于處理大量的垃圾請求,而無暇顧及正常請求。
Slowloris 攻擊
以非常低的速度發送 HTTP 請求頭,消耗服務器連接資源。
如:攻擊者發送大量 HTTP 請求,但每個請求頭都發的很慢,每隔 10s 發送一個字符,服務器為了等待數據,不得始終保持連接,這樣一來,服務器連接數很快就被占光了。
HTTP POST DOS
發送 HTTP 時,指定一個非常大的 Content-Length 然后以很長的間隔發送,消耗服務器連接資源。
CC 攻擊
針對一些非常消耗資源的頁面,不斷發起請求。
如:頁面中的某些頁面,需要后端做大量的運算,或者需要做非常耗時的數據庫查詢。在大量的請求下,服務器的 CPU、內存等資源可能就被占光了。
Server Limit DOS
通過 XSS 注入一段超長的 Cookie,導致超出 Web 服務器所能承受的 Request Header 長度,服務器端就會拒絕此服務。
ReDOS
針對一些缺陷的正則表達式,發起大量請求,耗光系統資源。
應用層拒絕服務攻擊防御
對于應用層拒絕服務攻擊,目前也沒有特別完美的解決方案,不過我們還是可以進行一些優化。
應用代碼做好性能優化
合理使用 Redis、Memcache 等緩存方案,減少 CPU 資源使用率。
網絡架構上做好優化
后端搭建負載均衡。
靜態資源使用 CDN 進行管理。
限制請求頻率
服務器計算所有 IP 地址的請求頻率,篩選出異常的 IP 進行禁用。
可以使用 LRU 算法,緩存前 1000 條請求的 IP,如果有 IP 請求頻率過高,就進行禁用。
其實,處理 DDOS 核心思路就是禁用不可信任的用戶,確保資源都是被正常的用戶所使用。
WebServer 配置安全
我們在部署 web 應用的時候,經常會用到 Nginx、Apache、IIS、Tomcat、Jboss 等 Web 服務器,這些服務器本身也存在一些安全隱患,如果配置不當,很容易收到攻擊。
在配置 Web 服務器時,可以參考以下幾點:
以用戶權限運行 Web 服務器
遵守最小權限原則,以最小權限身份運行 Web 服務器,限制被入侵后的權限。
刪除可視化后臺
運行 Tomcat、Jboss 等 Web 服務器時,默認會開啟一個可視化的運營后臺,運行在 8080 端口,并且第一次訪問是沒有認證的。
攻擊者可以利用可視化后臺,遠程加載一段 war 包或者上傳木馬文件,進行控制。
及時更新版本
主流的 Web 服務器,每隔一段時間就會修復一些漏洞,所以記得及時更新版本。
總結與思考
本文介紹了 Web 安全的基本概念,以及大量的攻防技巧,其實這只是 Web 安全中的冰山一角,如果你對此感興趣,不妨在安全領域繼續深耕學習,一定能看到更廣闊一片天。
對于一個開發者來說,我們應該在寫代碼時就將安全考慮其中,形成自己的一套安全開發體系,做到心中有安全,時時考慮安全,就能無形之中化解不法分子的攻擊。
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
CSS :before 選擇器
定義和說明
:before 選擇器向選定的元素前插入內容。
使用content 屬性來指定要插入的內容。
CSS :after 選擇器
定義和說明
:after 選擇器向選定的元素之后插入內容。
使用content 屬性來指定要插入的內容。
這兩個偽元素會在真正頁面元素之前和之后插入一個額外的元素,從技術角度上講,它們與下面的HTML標記是等效的。
1.偽類光圈
<div class="hover-circle">CSS</div>
.hover-circle {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 3rem;
letter-spacing: 0.3rem;
font-weight: bold;
position: relative;
cursor: pointer;
color: #666;
}
.hover-circle::before {
width: 8.5rem;
height: 8.5rem;
border: 3px solid pink;
content: "";
border-radius: 50%;
position: absolute;
opacity: 0;
}
.hover-circle::after {
width: 7.2rem;
height: 7.2rem;
border: 6px solid pink;
content: "";
border-radius: 50%;
position: absolute;
opacity: 0;
}
.hover-circle:hover::before,
.hover-circle:hover::after {
animation-duration: 0.8s;
animation-delay: 0.2s;
animation: circle 0.8s;
}
@keyframes circle {
0% {
opacity: 0;
scale: 1;
}
25% {
opacity: 0.25;
}
50% {
opacity: 0.5;
scale: 1.03;
}
75% {
opacity: 0.75;
}
100% {
opacity: 1;
scale: 1.03;
}
}
2.偽類括號效果
<div class="hover-text">CSS</div>
.hover-text {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 3rem;
letter-spacing: 0.3rem;
font-weight: bold;
position: relative;
cursor: pointer;
color: #666;
}
.hover-text::before {
content: "[";
position: absolute;
left: 0.8rem;
opacity: 0;
color: #999;
}
.hover-text::after {
content: "]";
position: absolute;
right: 0.8rem;
opacity: 0;
color: #999;
}
.hover-text:hover::before {
animation-duration: 0.8s;
animation-delay: 0.2s;
animation: hovertext1 0.8s;
}
.hover-text:hover::after {
animation-duration: 0.8s;
animation-delay: 0.2s;
animation: hovertext2 0.8s;
}
@keyframes hovertext1 {
0% {
opacity: 0;
left: 0.8rem;
}
100% {
opacity: 1;
left: 0.5rem;
}
}
@keyframes hovertext2 {
0% {
opacity: 0;
right: 0.8rem;
}
100% {
opacity: 1;
right: 0.5rem;
}
}
3.炫酷絲帶效果
雙邊絲帶
<div class="tc">
<div class="title1"><span>距離結束還有10天</span></div>
</div>
.title1 {
position: relative;
display: inline-block;
}
.title1 span {
position: relative;
z-index: 2;
display: inline-block;
padding: 0 15px;
height: 32px;
line-height: 32px;
background-color: #dc5947;
color: #fff;
font-size: 16px;
box-shadow: 0 10px 6px -9px rgba(0, 0, 0, 0.6);
}
.title1 span::before,
.title1 span::after {
position: absolute;
bottom: -6px;
border-width: 3px 5px;
border-style: solid;
content: "";
}
.title1 span::before {
left: 0;
border-color: #972f22 #972f22 transparent transparent;
}
.title1 span::after {
right: 0;
border-color: #972f22 transparent transparent #972f22;
}
.title1::before,
.title1::after {
position: absolute;
top: 6px;
content: "";
border-style: solid;
border-color: #dc5947;
}
.title1::before {
left: -32px;
border-width: 16px 26px 16px 16px;
border-left-color: transparent;
}
.title1::after {
right: -32px;
border-width: 16px 16px 16px 26px;
border-right-color: transparent;
}
右邊絲帶
<span class="title2">距離結束還有10天</span>
.title2 {
position: relative;
display: inline-block;
padding: 0 15px;
height: 32px;
line-height: 32px;
background-color: #dc5947;
color: #fff;
font-size: 16px;
}
.title2::before {
position: absolute;
top: -4px;
left: 0;
border-width: 2px 4px;
border-style: solid;
border-color: transparent #972f22 #972f22 transparent;
content: "";
}
.title2::after {
position: absolute;
top: 0;
right: -8px;
border-width: 16px 8px 16px 0;
border-style: solid;
border-color: #dc5947 transparent #dc5947 #dc5947;
content: "";
}
箭頭絲帶
<span class="title3">距離結束還有10天</span>
.title3 {
position: relative;
display: inline-block;
margin-right: 16px;
padding: 0 10px;
height: 32px;
line-height: 32px;
background-color: #dc5947;
color: #fff;
font-size: 16px;
}
.title3::before {
position: absolute;
top: 0;
left: -16px;
border-width: 16px 16px 16px 0;
border-style: solid;
border-color: transparent #dc5947 transparent transparent;
content: "";
}
.title3::after {
position: absolute;
top: 0;
right: -16px;
border-width: 16px 16px 16px 0;
border-style: solid;
border-color: #dc5947 transparent #dc5947 #dc5947;
content: "";
}
多個箭頭絲帶
<div class="mt30 pl16">
<span class="title3">距離結束還有10天</span>
<span class="title3 ml5">距離結束還有10天</span>
<span class="title3 ml5">距離結束還有10天</span>
</div>
.title4 {
width: 200px;
height: 140px;
position: absolute;
top: -8px;
left: -8px;
overflow: hidden;
}
.title4::before {
position: absolute;
left: 124px;
border-radius: 8px 8px 0 0;
width: 16px;
height: 8px;
background-color: #972f22;
content: "";
}
.title4::after {
position: absolute;
left: 0;
top: 124px;
border-radius: 0 8px 8px 0;
width: 8px;
height: 16px;
background-color: #972f22;
content: "";
}
.title4 span {
display: inline-block;
text-align: center;
width: 200px;
height: 40px;
line-height: 40px;
position: absolute;
top: 30px;
left: -50px;
z-index: 2;
overflow: hidden;
-ms-transform: rotate(-45deg);
-moz-transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
-o-transform: rotate(-45deg);
transform: rotate(-45deg);
border: 1px dashed #fff;
box-shadow: 0 0 0 3px #dc5947, 0 14px 7px -9px rgba(0, 0, 0, 0.6);
background-color: #dc5947;
color: #fff;
}
懸掛標簽
<div class="pr mt30" style="background-color: #eee; height: 200px;">
<div class="title4"><span>企業熱門動態</span></div>
<div class="title5"><span>企業熱門動態</span></div>
</div>
.title5 {
width: 140px;
height: 200px;
position: absolute;
top: -8px;
right: -8px;
overflow: hidden;
}
.title5::before {
position: absolute;
right: 124px;
border-radius: 8px 8px 0 0;
width: 16px;
height: 8px;
background-color: #972f22;
content: "";
}
.title5::after {
position: absolute;
right: 0;
top: 124px;
border-radius: 0 8px 8px 0;
width: 8px;
height: 16px;
background-color: #972f22;
content: "";
}
.title5 span {
display: inline-block;
text-align: center;
width: 200px;
height: 40px;
line-height: 40px;
position: absolute;
top: 30px;
right: -50px;
z-index: 2;
overflow: hidden;
-ms-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-webkit-transform: rotate(45deg);
-o-transform: rotate(45deg);
transform: rotate(45deg);
border: 1px dashed #fff;
box-shadow: 0 0 0 3px #dc5947, 0 14px 7px -9px rgba(0, 0, 0, 0.6);
background-color: #dc5947;
color: #fff;
}
4.幾何圖形
三角形
<div class="triangle"></div>
.triangle {
width: 0;
height: 0;
margin: 50px auto;
border-bottom: 100px solid #dc5947;
border-left: 50px solid transparent;
border-right: 50px solid transparent;
cursor: pointer;
transform: scale(1.2);
transition: 0.5s;
}
五角星
<div class="pentagram"></div>
.pentagram {
width: 0;
height: 0;
margin: 100px auto;
position: relative;
border-bottom: 70px solid #dc5947;
border-left: 100px solid transparent;
border-right: 100px solid transparent;
-webkit-transform: rotate(35deg);
-moz-transform: rotate(35deg);
-ms-transform: rotate(35deg);
-o-transform: rotate(35deg);
transform: rotate(35deg);
-webkit-transform: scale(1), rotate(35deg);
-moz-transform: scale(1), rotate(35deg);
-ms-transform: scale(1), rotate(35deg);
-o-transform: scale(1), rotate(35deg);
transform: scale(1), rotate(35deg);
}
.pentagram::after {
content: "";
width: 0;
height: 0;
border-bottom: 70px solid #dc5947;
border-left: 100px solid transparent;
border-right: 100px solid transparent;
-webkit-transform: rotate(-70deg);
-moz-transform: rotate(-70deg);
-ms-transform: rotate(-70deg);
-o-transform: rotate(-70deg);
transform: rotate(-70deg);
position: absolute;
top: 0px;
left: -100px;
}
.pentagram::before {
content: "";
width: 0;
height: 0;
border-bottom: 80px solid #dc5947;
border-left: 30px solid transparent;
border-right: 30px solid transparent;
-webkit-transform: rotate(-35deg);
-moz-transform: rotate(-35deg);
-ms-transform: rotate(-35deg);
-o-transform: rotate(-35deg);
transform: rotate(-35deg);
position: absolute;
top: -45px;
left: -60px;
}
5.水滴
<div class="drop"></div>
.drop::after {
content: "";
position: absolute;
width: 30px;
height: 20px;
border-radius: 50%;
background-color: #ace3ff;
margin: 100px auto;
top: -50px;
left: 25px;
box-shadow: 5px 12px 4px #ace3ff, -5px 11px 4px #ace3ff, 0px 14px 4px #4d576e;
-webkit-transform: rotate(35deg);
}
.drop::before {
content: "";
position: absolute;
width: 0px;
height: 0px;
border-style: solid;
border-width: 0 40px 50px 40px;
border-color: transparent transparent #ace3ff transparent;
top: -30px;
left: 10px;
}
.drop {
width: 100px;
height: 100px;
border-radius: 50%;
background-color: #ace3ff;
position: relative;
margin: 100px auto;
box-shadow: 0px 6px 0 #3f475a;
}
6 絢麗流動邊框
<div class="box-line1"></div>
.box-line2,
.box-line2::before,
.box-line2::after {
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
}
.box-line2 {
width: 200px;
height: 200px;
margin: auto;
color: #69ca62;
box-shadow: inset 0 0 0 1px rgba(105, 202, 98, 0.5);
}
.box-line2::before,
.box-line2::after {
content: "";
z-index: 99;
margin: -5%;
box-shadow: inset 0 0 0 2px;
animation: clipMe 8s linear infinite;
}
.box-line2::before {
animation-delay: -4s;
}
.box-line2:hover::after,
.box-line2:hover::before {
background-color: rgba(255, 0, 0, 0.3);
}
@keyframes clipMe {
0%,
100% {
clip: rect(0px, 220px, 2px, 0px);
}
25% {
clip: rect(0px, 2px, 220px, 0px);
}
50% {
clip: rect(218px, 220px, 220px, 0px);
}
75% {
clip: rect(0px, 220px, 220px, 218px);
}
}
@keyframes surround {
0%,
100% {
clip: rect(0px, 220px, 2px, 0px);
}
25% {
clip: rect(0px, 2px, 220px, 0px);
}
50% {
clip: rect(218px, 220px, 220px, 0px);
}
75% {
clip: rect(0px, 220px, 220px, 218px);
}
}
.box-line1:before,
.box-line1:after {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
content: "";
z-index: 99;
margin: -5%;
animation: surround linear infinite 8s;
box-shadow: inset 0 0 0 2px #69ca62;
}
.box-line1:before {
animation-delay: -4s;
}
.box-line1 {
border: 1px solid #69ca62;
position: absolute;
left: 500px;
top: 200px;
margin: auto;
width: 200px;
height: 200px;
margin: auto;
}
7.Tooltip提示
<div class="tip" data-tip="CSS偽類">CSS偽類</div>
.tip::after {
content: attr(data-tip);
display: none;
position: absolute;
padding: 5px 10px;
left: 15%;
bottom: 100%;
width: 150px;
margin-bottom: 12px;
transform: translateX(-50%);
font-size: 12px;
background: #000;
color: #fff;
cursor: default;
border-radius: 4px;
}
.tip::before {
content: " ";
position: absolute;
display: none;
left: 15%;
bottom: 100%;
transform: translateX(-50%);
margin-bottom: 3px;
width: 0;
height: 0;
border-left: 6px solid transparent;
border-right: 6px solid transparent;
border-top: 9px solid #000;
}
.tip:hover::after,
.tip:hover::before {
display: block;
}
8.CSS 偽類盒子陰影
使用偽元素:before and :after制作出了完美驚艷的相片陰影效果。其中的技巧是使用絕對定位固定偽元素,然后給它們的z-index一個負值,以背景出現。
<div class="box effect2">
<h3>CSS 偽類盒子陰影</h3>
</div>
.effect2 {
position: relative;
}
.effect2::before, .effect2::after {
z-index: -1;
position: absolute;
content: "";
bottom: 15px;
left: 10px;
width: 50%;
top: 80%;
max-width: 300px;
background: #777;
-webkit-box-shadow: 0 15px 10px #777;
-moz-box-shadow: 0 15px 10px #777;
box-shadow: 0 15px 10px #777;
-webkit-transform: rotate(-3deg);
-moz-transform: rotate(-3deg);
-o-transform: rotate(-3deg);
-ms-transform: rotate(-3deg);
transform: rotate(-3deg);
}
.effect2::after {
-webkit-transform: rotate(3deg);
-moz-transform: rotate(3deg);
-o-transform: rotate(3deg);
-ms-transform: rotate(3deg);
transform: rotate(3deg);
right: 10px;
left: auto;
}
CSS Box 陰影效果
9.Tabs當前激活狀態
<div class="sm-box flex">
<div class="menu-tabs active">首頁</div>
<div class="menu-tabs">新聞</div>
<div class="menu-tabs">視頻</div>
<div class="menu-tabs">圖片</div>
</div>
.menu-tabs {
display: block;
padding: 0.25rem 1.5rem;
clear: both;
font-weight: 400;
color: #212529;
text-align: inherit;
white-space: nowrap;
background-color: transparent;
width: 50px;
border: 0;
height: 35px;
justify-content: center;
display: flex;
cursor: pointer;
}
.menu-tabs:hover {
color: #20a884;
position: relative;
}
.menu-tabs:hover:after {
position: absolute;
content: "";
border: 1px solid #20a884;
width: 3rem;
left: 0;
bottom: 0;
margin-left: 50%;
transform: translateX(-50%);
}
.active {
position: relative;
color: #20a884;
}
.flex {
display: flex;
}
.active::after {
position: absolute;
content: "";
border: 1px solid #20a884;
width: 3rem;
left: 0;
bottom: 0;
margin-left: 50%;
transform: translateX(-50%);
}
10.偽元素模糊背景
<div class="container">
<div class="overlay">
<h1>A blurred overlay</h1>
<p>... mask or whatever
<br>that is responsive and could be cross-browser compatible back to IE9</p>
</div>
</div>
.container {
width: 100%;
height: 100%;
margin: 0;
}
.container,
.overlay:before {
background: url(https://wow.techbrood.com/assets/landing.jpg) no-repeat fixed 0 0 / cover;
}
.container {
-webkit-box-align: center;
-webkit-align-items: center;
-ms-flex-align: center;
align-items: center;
display: -webkit-box;
display: -webkit-flex;
display: -ms-flexbox;
display: flex;
-webkit-box-pack: center;
-webkit-justify-content: center;
-ms-flex-pack: center;
justify-content: center;
}
.overlay {
max-height: 200px;
margin: 0 auto;
max-width: 768px;
padding: 50px;
position: relative;
color: white;
font-family: "Lato";
position: relative;
text-align: center;
z-index: 0;
}
.overlay:before {
content: "";
-webkit-filter: blur(100px);
filter: blur(100px);
height: 100%;
left: 0;
position: absolute;
top: 0;
width: 100%;
z-index: -1;
}
11.藍湖文字
<span class="lanhu_text">
本站由叫我詹躲躲提供技術支持
</span>
.lanhu_text {
position: relative;
color: #2878ff;
}
.lanhu_text::before {
content: "";
width: 80px;
height: 20px;
position: absolute;
left: -86px;
top: 0;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAABCAYAAABJwyn/AAAAjElEQVQoU22NSw7CQAxDX8ahICF2HIDTcf9d1c8kaDpthQSL6CmxHRuk8cZfMxqf6DGh+Y5uCxquaB7xdUCXilZHWvBorEiOaqAKrkRRUEmUOw283TKRRb9b4GnIEpWmGYrA237kDh1w6J5N7zzzZv13gtuvT7t++jefUTYmwvpk7v3fPaCzn//9LfsBvRpHnliu+xMAAAAASUVORK5CYII=) 0 no-repeat;
}
.lanhu_text::after {
content: "";
width: 80px;
height: 20px;
position: absolute;
right: -86px;
top: 0;
background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFAAAAABCAYAAABJwyn/AAAAhElEQVQoU22OSw7CQAxDXzJDWbHiEFyRJQflFggJSjyDph9oC6snx45lO52rrKJqzIyNlkFUPv6vnv+3uW9vrPpBq77/uWFPAXnrnfYVQ16JiWq0stjvxKB9vDc/MeaXWo5cqDEFUkKpEuEoB3ollHvUZ9QV4rFD3R3d9ujwJK5HxAW9AStemAIOwCNlAAAAAElFTkSuQmCC) 100% no-repeat;
}
12 主要標題
<div class="first-title">服務項目</div>
.first-title {
position: relative;
color: #a98661;
font-weight: 400;
font-size: 30px;
text-align: center;
}
.first-title::before,
.first-title::after {
position: absolute;
content: "";
width: 110px;
border-bottom: 1px solid #a98661;
top: 50%;
transform: translateY(-50%);
}
.first-title::before {
left: 100px;
}
.first-title::after {
right: 100px;
}
13.鼠標浮層遮罩浮層
<div class="black-mask"></div>
.black-mask {
position: relative;
height: 100%;
width: 100%;
cursor: pointer;
}
.black-mask:hover {
transition-duration: 1s;
scale: 1.02;
}
.black-mask:hover:before {
object-fit: cover;
}
.black-mask:hover:after {
height: 100%;
opacity: 1;
transition-duration: 1s;
display: flex;
align-items: flex-end;
padding: 0 30px 15px;
}
.black-mask::before {
position: absolute;
content: "";
background: url(https://dcdn.it120.cc/2019/11/14/f17c5848-6d1f-4254-b3ba-64d3969d16b6.jpg) no-repeat;
background-size: 100% 100%;
width: 100%;
height: 100%;
}
.black-mask::after {
position: absolute;
content: "霧在微風的吹動下滾來滾去,像冰峰雪山,似蓬萊仙境,如海市蜃樓,使人覺得飄然欲仙。山河景色在霧的裝點下,變得更加美麗。遠處的七連山巍峨挺拔,它們仿佛成了神仙住的寶山,令人神往。近處池塘邊時時飄來霧氣,在初升陽光的照耀下,呈現出赤、橙、黃、綠、青、藍、紫七種色彩。......";
width: 90%;
height: 0%;
bottom: 0;
right: 0;
z-index: 32;
background: rgba(0, 0, 0, 0.3);
opacity: 1;
color: #fff;
opacity: 0;
padding: 0 30px 0;
}
14.絢麗光圈
<div class="aperture">光圈</div>
.aperture {
width: 136px;
height: 136px;
background-color: #dc5947;
border-radius: 50%;
line-height: 136px;
text-align: center;
color: #fff;
font-size: 24px;
cursor: pointer;
position: relative;
}
.aperture::before {
border: 3px dashed #a0ff80;
content: "";
width: 144px;
height: 144px;
position: absolute;
border-radius: 50%;
left: -8px;
top: -6px;
animation: clockwise 5s linear infinite;
}
@keyframes clockwise {
100% {
transform: rotate(360deg);
}
}
15.彩色流動邊框
<div class="rainbow"></div>
.rainbow {
position: relative;
z-index: 0;
width: 400px;
height: 300px;
border-radius: 10px;
overflow: hidden;
padding: 2rem;
}
.rainbow::before {
content: '';
position: absolute;
z-index: -2;
left: -50%;
top: -50%;
width: 200%;
height: 200%;
background-color: #399953;
background-repeat: no-repeat;
background-size: 50% 50%, 50% 50%;
background-position: 0 0, 100% 0, 100% 100%, 0 100%;
background-image: linear-gradient(#399953, #399953), linear-gradient(#fbb300, #fbb300), linear-gradient(#d53e33, #d53e33), linear-gradient(#377af5, #377af5);
-webkit-animation: rotate 4s linear infinite;
animation: rotate 4s linear infinite;
}
.rainbow::after {
content: '';
position: absolute;
z-index: -1;
left: 6px;
top: 6px;
width: calc(100% - 12px);
height: calc(100% - 12px);
background: white;
border-radius: 5px;
}
@keyframes rotate {
100% {
-webkit-transform: rotate(1turn);
transform: rotate(1turn);
}
}
16.炫酷偽類邊框
<div class="corner-button">CSS3</div>
.corner-button::before, .corner-button::after {
content: '';
position: absolute;
background: #2f2f2f;
z-index: 1;
transition: all 0.3s;
}
.corner-button::before {
width: calc(100% - 3rem);
height: calc(101% + 1rem);
top: -0.5rem;
left: 50%;
-webkit-transform: translateX(-50%);
transform: translateX(-50%);
}
.corner-button::after {
height: calc(100% - 3rem);
width: calc(101% + 1rem);
left: -0.5rem;
top: 50%;
-webkit-transform: translateY(-50%);
transform: translateY(-50%);
}
.corner-button:hover {
color: pink;
}
.corner-button {
font-family: 'Lato', sans-serif;
letter-spacing: .02rem;
cursor: pointer;
background: transparent;
border: 0.5rem solid currentColor;
padding: 1.5rem 2rem;
font-size: 2.2rem;
color: #06c17f;
position: relative;
transition: color 0.3s;
text-align: center;
margin: 5rem 12rem;
}
.corner-button:hover::after {
height: 0;
}
.corner-button:hover::before {
width: 0;
}
.bg-f2{
background: #2f2f2f;
}
17.偽類美化文字
<div class="beautify-font" data-text='躲躲'>躲躲</div>
<div class="beautify-font2" data-text='躲躲'>躲躲</div>
.beautify-font{
position: relative;
font-size: 12rem;
color: #0099CC
}
.beautify-font::before{
position: absolute;
font-size: 12rem;
color: #333;
content: attr(data-text);
white-space:nowrap;
width: 50%;
display: inline-block;
overflow: hidden;
transition:1s ease-in-out 0s;
}
.beautify-font2{
position: relative;
font-size: 6rem;
color: #0099CC
}
.beautify-font2::before{
position: absolute;
font-size: 6rem;
color: #333;
content: attr(data-text);
white-space:nowrap;
height: 50%;
display: inline-block;
overflow: hidden;
transition:1s ease-in-out 0s;
}
.beautify-font:hover::before{
width:0;
}
.beautify-font2:hover::before{
height: 0;
}
18.照片堆疊效果
只使用一張圖片來創造出一堆圖片疊摞在一起的效果,能做到嗎?當然,關鍵是要使用偽元素:before和:after來幫助呈現。把這些偽元素的z-index設置成負值,讓它們以背景方式起作用。
<div class="stackthree"><img src="./images/city.jpg"></div>
.stackthree::before {
background: #eff4de;
}
.stackthree, .stackthree::before, .stackthree::after {
border: 6px solid #fff;
height: 200px;
width: 200px;
-webkit-box-shadow: 2px 2px 5px rgba(0,0,0,0.3);
-moz-box-shadow: 2px 2px 5px rgba(0,0,0,0.3);
box-shadow: 2px 2px 5px rgba(0,0,0,0.3);
}
.stackthree::before {
top: 5px;
left: -15px;
z-index: -1;
-webkit-transform: rotate(-10deg);
-moz-transform: rotate(-10deg);
-o-transform: rotate(-10deg);
-ms-transform: rotate(-10deg);
transform: rotate(-10deg);
}
.stackthree::after {
top: -2px;
left: -10px;
-webkit-transform: rotate(-5deg);
-moz-transform: rotate(-5deg);
-o-transform: rotate(-5deg);
-ms-transform: rotate(-5deg);
transform: rotate(-5deg);
}
.stackthree::before, .stackthree::after {
background: #768590;
content: "";
position: absolute;
z-index: -1;
height: 0px\9;
width: 0px\9;
border: none\9;
}
.stackthree {
float: left;
position: relative;
margin: 50px;
}
為元素的兼容性
不論你使用單冒號還是雙冒號語法,瀏覽器都能識別。因為IE8只支持單冒號的語法,所以,如果你想兼容IE8,保險的做法是使用單冒號。
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
在最后的 阿寶哥有話說 環節,阿寶哥將介紹 WebSocket 與 HTTP 之間的關系、WebSocket 與長輪詢有什么區別、什么是 WebSocket 心跳及 Socket 是什么等內容。
下面我們進入正題,為了讓大家能夠更好地理解和掌握 WebSocket 技術,我們先來介紹一下什么是 WebSocket。
一、什么是 WebSocket
1.1 WebSocket 誕生背景
早期,很多網站為了實現推送技術,所用的技術都是輪詢。輪詢是指由瀏覽器每隔一段時間向服務器發出 HTTP 請求,然后服務器返回的數據給客戶端。常見的輪詢方式分為輪詢與長輪詢,它們的區別如下圖所示:
為了更加直觀感受輪詢與長輪詢之間的區別,我們來看一下具體的代碼:
這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向服務器發出請求,然而 HTTP 請求與響應可能會包含較長的頭部,其中真正有效的數據可能只是很小的一部分,所以這樣會消耗很多帶寬資源。
比較新的輪詢技術是 Comet)。這種技術雖然可以實現雙向通信,但仍然需要反復發出請求。而且在 Comet 中普遍采用的 HTTP 長連接也會消耗服務器資源。
在這種情況下,HTML5 定義了 WebSocket 協議,能更好的節省服務器資源和帶寬,并且能夠更實時地進行通訊。Websocket 使用 ws 或 wss 的統一資源標志符(URI),其中 wss 表示使用了 TLS 的 Websocket。如:
ws://echo.websocket.org
wss://echo.websocket.org
WebSocket 與 HTTP 和 HTTPS 使用相同的 TCP 端口,可以繞過大多數防火墻的限制。默認情況下,WebSocket 協議使用 80 端口;若運行在 TLS 之上時,默認使用 443 端口。
1.2 WebSocket 簡介
WebSocket 是一種網絡傳輸協議,可在單個 TCP 連接上進行全雙工通信,位于 OSI 模型的應用層。WebSocket 協議在 2011 年由 IETF 標準化為 RFC 6455,后由 RFC 7936 補充規范。
WebSocket 使得客戶端和服務器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和服務器只需要完成一次握手,兩者之間就可以創建持久性的連接,并進行雙向數據傳輸。
介紹完輪詢和 WebSocket 的相關內容之后,接下來我們來看一下 XHR Polling 與 WebSocket 之間的區別:
1.3 WebSocket 優點
較少的控制開銷。在連接創建后,服務器和客戶端之間交換數據時,用于協議控制的數據包頭部相對較小。
更強的實時性。由于協議是全雙工的,所以服務器可以隨時主動給客戶端下發數據。相對于 HTTP 請求需要等待客戶端發起請求服務端才能響應,延遲明顯更少。
保持連接狀態。與 HTTP 不同的是,WebSocket 需要先創建連接,這就使得其成為一種有狀態的協議,之后通信時可以省略部分狀態信息。
更好的二進制支持。WebSocket 定義了二進制幀,相對 HTTP,可以更輕松地處理二進制內容。
可以支持擴展。WebSocket 定義了擴展,用戶可以擴展協議、實現部分自定義的子協議。
由于 WebSocket 擁有上述的優點,所以它被廣泛地應用在即時通信、實時音視頻、在線教育和游戲等領域。對于前端開發者來說,要想使用 WebSocket 提供的強大能力,就必須先掌握 WebSocket API,下面阿寶哥帶大家一起來認識一下 WebSocket API。
二、WebSocket API
在介紹 WebSocket API 之前,我們先來了解一下它的兼容性:
(圖片來源:https://caniuse.com/#search=W...)
從上圖可知,目前主流的 Web 瀏覽器都支持 WebSocket,所以我們可以在大多數項目中放心地使用它。
在瀏覽器中要使用 WebSocket 提供的能力,我們就必須先創建 WebSocket 對象,該對象提供了用于創建和管理 WebSocket 連接,以及可以通過該連接發送和接收數據的 API。
使用 WebSocket 構造函數,我們就能輕易地構造一個 WebSocket 對象。接下來我們將從 WebSocket 構造函數、WebSocket 對象的屬性、方法及 WebSocket 相關的事件四個方面來介紹 WebSocket API,首先我們從 WebSocket 的構造函數入手:
2.1 構造函數
WebSocket 構造函數的語法為:
const myWebSocket = new WebSocket(url [, protocols]);
相關參數說明如下:
url:表示連接的 URL,這是 WebSocket 服務器將響應的 URL。
protocols(可選):一個協議字符串或者一個包含協議字符串的數組。這些字符串用于指定子協議,這樣單個服務器可以實現多個 WebSocket 子協議。比如,你可能希望一臺服務器能夠根據指定的協議(protocol)處理不同類型的交互。如果不指定協議字符串,則假定為空字符串。
當嘗試連接的端口被阻止時,會拋出 SECURITY_ERR 異常。
2.2 屬性
WebSocket 對象包含以下屬性:
每個屬性的具體含義如下:
binaryType:使用二進制的數據類型連接。
bufferedAmount(只讀):未發送至服務器的字節數。
extensions(只讀):服務器選擇的擴展。
onclose:用于指定連接關閉后的回調函數。
onerror:用于指定連接失敗后的回調函數。
onmessage:用于指定當從服務器接受到信息時的回調函數。
onopen:用于指定連接成功后的回調函數。
protocol(只讀):用于返回服務器端選中的子協議的名字。
readyState(只讀):返回當前 WebSocket 的連接狀態,共有 4 種狀態:
CONNECTING — 正在連接中,對應的值為 0;
OPEN — 已經連接并且可以通訊,對應的值為 1;
CLOSING — 連接正在關閉,對應的值為 2;
CLOSED — 連接已關閉或者沒有連接成功,對應的值為 3。
url(只讀):返回值為當構造函數創建 WebSocket 實例對象時 URL 的絕對路徑。
2.3 方法
close([code[, reason]]):該方法用于關閉 WebSocket 連接,如果連接已經關閉,則此方法不執行任何操作。
send(data):該方法將需要通過 WebSocket 鏈接傳輸至服務器的數據排入隊列,并根據所需要傳輸的數據的大小來增加 bufferedAmount 的值 。若數據無法傳輸(比如數據需要緩存而緩沖區已滿)時,套接字會自行關閉。
2.4 事件
使用 addEventListener() 或將一個事件監聽器賦值給 WebSocket 對象的 oneventname 屬性,來監聽下面的事件。
close:當一個 WebSocket 連接被關閉時觸發,也可以通過 onclose 屬性來設置。
error:當一個 WebSocket 連接因錯誤而關閉時觸發,也可以通過 onerror 屬性來設置。
message:當通過 WebSocket 收到數據時觸發,也可以通過 onmessage 屬性來設置。
open:當一個 WebSocket 連接成功時觸發,也可以通過 onopen 屬性來設置。
介紹完 WebSocket API,我們來舉一個使用 WebSocket 發送普通文本的示例。
2.5 發送普通文本
在以上示例中,我們在頁面上創建了兩個 textarea,分別用于存放 待發送的數據 和 服務器返回的數據。當用戶輸入完待發送的文本之后,點擊 發送 按鈕時會把輸入的文本發送到服務端,而服務端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。
// const socket = new WebSocket("ws://echo.websocket.org");
// const sendMsgContainer = document.querySelector("#sendMessage");
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("連接未建立,還不能發送消息");
return;
}
if (message) socket.send(message);
}
當然客戶端接收到服務端返回的消息之后,會把對應的文本內容保存到 接收的數據 對應的 textarea 文本框中。
// const socket = new WebSocket("ws://echo.websocket.org");
// const receivedMsgContainer = document.querySelector("#receivedMessage");
socket.addEventListener("message", function (event) {
console.log("Message from server ", event.data);
receivedMsgContainer.value = event.data;
});
為了更加直觀地理解上述的數據交互過程,我們使用 Chrome 瀏覽器的開發者工具來看一下相應的過程:
以上示例對應的完整代碼如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket 發送普通文本示例</title>
<style>
.block {
flex: 1;
}
</style>
</head>
<body>
<h3>阿寶哥:WebSocket 發送普通文本示例</h3>
<div style="display: flex;">
<div class="block">
<p>即將發送的數據:<button onclick="send()">發送</button></p>
<textarea id="sendMessage" rows="5" cols="15"></textarea>
</div>
<div class="block">
<p>接收的數據:</p>
<textarea id="receivedMessage" rows="5" cols="15"></textarea>
</div>
</div>
<script>
const sendMsgContainer = document.querySelector("#sendMessage");
const receivedMsgContainer = document.querySelector("#receivedMessage");
const socket = new WebSocket("ws://echo.websocket.org");
// 監聽連接成功事件
socket.addEventListener("open", function (event) {
console.log("連接成功,可以開始通訊");
});
// 監聽消息
socket.addEventListener("message", function (event) {
console.log("Message from server ", event.data);
receivedMsgContainer.value = event.data;
});
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("連接未建立,還不能發送消息");
return;
}
if (message) socket.send(message);
}
</script>
</body>
</html>
其實 WebSocket 除了支持發送普通的文本之外,它還支持發送二進制數據,比如 ArrayBuffer 對象、Blob 對象或者 ArrayBufferView 對象:
const socket = new WebSocket("ws://echo.websocket.org");
socket.onopen = function () {
// 發送UTF-8編碼的文本信息
socket.send("Hello Echo Server!");
// 發送UTF-8編碼的JSON數據
socket.send(JSON.stringify({ msg: "我是阿寶哥" }));
// 發送二進制ArrayBuffer
const buffer = new ArrayBuffer(128);
socket.send(buffer);
// 發送二進制ArrayBufferView
const intview = new Uint32Array(buffer);
socket.send(intview);
// 發送二進制Blob
const blob = new Blob([buffer]);
socket.send(blob);
};
以上代碼成功運行后,通過 Chrome 開發者工具,我們可以看到對應的數據交互過程:
下面阿寶哥以發送 Blob 對象為例,來介紹一下如何發送二進制數據。
Blob(Binary Large Object)表示二進制類型的大對象。在數據庫管理系統中,將二進制數據存儲為一個單一個體的集合。Blob 通常是影像、聲音或多媒體文件。在 JavaScript 中 Blob 類型的對象表示不可變的類似文件對象的原始數據。
對 Blob 感興趣的小伙伴,可以閱讀 “你不知道的 Blob” 這篇文章。
2.6 發送二進制數據
在以上示例中,我們在頁面上創建了兩個 textarea,分別用于存放 待發送的數據 和 服務器返回的數據。當用戶輸入完待發送的文本之后,點擊 發送 按鈕時,我們會先獲取輸入的文本并把文本包裝成 Blob 對象然后發送到服務端,而服務端成功接收到消息之后,會把收到的消息原封不動地回傳到客戶端。
當瀏覽器接收到新消息后,如果是文本數據,會自動將其轉換成 DOMString 對象,如果是二進制數據或 Blob 對象,會直接將其轉交給應用,由應用自身來根據返回的數據類型進行相應的處理。
數據發送代碼
// const socket = new WebSocket("ws://echo.websocket.org");
// const sendMsgContainer = document.querySelector("#sendMessage");
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("連接未建立,還不能發送消息");
return;
}
const blob = new Blob([message], { type: "text/plain" });
if (message) socket.send(blob);
console.log(`未發送至服務器的字節數:${socket.bufferedAmount}`);
}
當然客戶端接收到服務端返回的消息之后,會判斷返回的數據類型,如果是 Blob 類型的話,會調用 Blob 對象的 text() 方法,獲取 Blob 對象中保存的 UTF-8 格式的內容,然后把對應的文本內容保存到 接收的數據 對應的 textarea 文本框中。
數據接收代碼
// const socket = new WebSocket("ws://echo.websocket.org");
// const receivedMsgContainer = document.querySelector("#receivedMessage");
socket.addEventListener("message", async function (event) {
console.log("Message from server ", event.data);
const receivedData = event.data;
if (receivedData instanceof Blob) {
receivedMsgContainer.value = await receivedData.text();
} else {
receivedMsgContainer.value = receivedData;
}
});
同樣,我們使用 Chrome 瀏覽器的開發者工具來看一下相應的過程:
通過上圖我們可以很明顯地看到,當使用發送 Blob 對象時,Data 欄位的信息顯示的是 Binary Message,而對于發送普通文本來說,Data 欄位的信息是直接顯示發送的文本消息。
以上示例對應的完整代碼如下所示:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WebSocket 發送二進制數據示例</title>
<style>
.block {
flex: 1;
}
</style>
</head>
<body>
<h3>阿寶哥:WebSocket 發送二進制數據示例</h3>
<div style="display: flex;">
<div class="block">
<p>待發送的數據:<button onclick="send()">發送</button></p>
<textarea id="sendMessage" rows="5" cols="15"></textarea>
</div>
<div class="block">
<p>接收的數據:</p>
<textarea id="receivedMessage" rows="5" cols="15"></textarea>
</div>
</div>
<script>
const sendMsgContainer = document.querySelector("#sendMessage");
const receivedMsgContainer = document.querySelector("#receivedMessage");
const socket = new WebSocket("ws://echo.websocket.org");
// 監聽連接成功事件
socket.addEventListener("open", function (event) {
console.log("連接成功,可以開始通訊");
});
// 監聽消息
socket.addEventListener("message", async function (event) {
console.log("Message from server ", event.data);
const receivedData = event.data;
if (receivedData instanceof Blob) {
receivedMsgContainer.value = await receivedData.text();
} else {
receivedMsgContainer.value = receivedData;
}
});
function send() {
const message = sendMsgContainer.value;
if (socket.readyState !== WebSocket.OPEN) {
console.log("連接未建立,還不能發送消息");
return;
}
const blob = new Blob([message], { type: "text/plain" });
if (message) socket.send(blob);
console.log(`未發送至服務器的字節數:${socket.bufferedAmount}`);
}
</script>
</body>
</html>
可能有一些小伙伴了解完 WebSocket API 之后,覺得還不夠過癮。下面阿寶哥將帶大家來實現一個支持發送普通文本的 WebSocket 服務器。
三、手寫 WebSocket 服務器
在介紹如何手寫 WebSocket 服務器前,我們需要了解一下 WebSocket 連接的生命周期。
從上圖可知,在使用 WebSocket 實現全雙工通信之前,客戶端與服務器之間需要先進行握手(Handshake),在完成握手之后才能開始進行數據的雙向通信。
握手是在通信電路創建之后,信息傳輸開始之前。握手用于達成參數,如信息傳輸率,字母表,奇偶校驗,中斷過程,和其他協議特性。 握手有助于不同結構的系統或設備在通信信道中連接,而不需要人為設置參數。
既然握手是 WebSocket 連接生命周期的第一個環節,接下來我們就先來分析 WebSocket 的握手協議。
3.1 握手協議
WebSocket 協議屬于應用層協議,它依賴于傳輸層的 TCP 協議。WebSocket 通過 HTTP/1.1 協議的 101 狀態碼進行握手。為了創建 WebSocket 連接,需要通過瀏覽器發出請求,之后服務器進行回應,這個過程通常稱為 “握手”(Handshaking)。
利用 HTTP 完成握手有幾個好處。首先,讓 WebSocket 與現有 HTTP 基礎設施兼容:使得 WebSocket 服務器可以運行在 80 和 443 端口上,這通常是對客戶端唯一開放的端口。其次,讓我們可以重用并擴展 HTTP 的 Upgrade 流,為其添加自定義的 WebSocket 首部,以完成協商。
下面我們以前面已經演示過的發送普通文本的例子為例,來具體分析一下握手過程。
3.1.1 客戶端請求
GET ws://echo.websocket.org/ HTTP/1.1
Host: echo.websocket.org
Origin: file://
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: Zx8rNEkBE4xnwifpuh8DHQ==
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
備注:已忽略部分 HTTP 請求頭
字段說明
Connection 必須設置 Upgrade,表示客戶端希望連接升級。
Upgrade 字段必須設置 websocket,表示希望升級到 WebSocket 協議。
Sec-WebSocket-Version 表示支持的 WebSocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用。
Sec-WebSocket-Key 是隨機的字符串,服務器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后計算 SHA-1 摘要,之后進行 Base64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以盡量避免普通 HTTP 請求被誤認為 WebSocket 協議。
Sec-WebSocket-Extensions 用于協商本次連接要使用的 WebSocket 擴展:客戶端發送支持的擴展,服務器通過返回相同的首部確認自己支持一個或多個擴展。
Origin 字段是可選的,通常用來表示在瀏覽器中發起此 WebSocket 連接所在的頁面,類似于 Referer。但是,與 Referer 不同的是,Origin 只包含了協議和主機名稱。
3.1.2 服務端響應
HTTP/1.1 101 Web Socket Protocol Handshake ①
Connection: Upgrade ②
Upgrade: websocket ③
Sec-WebSocket-Accept: 52Rg3vW4JQ1yWpkvFlsTsiezlqw= ④
備注:已忽略部分 HTTP 響應頭
① 101 響應碼確認升級到 WebSocket 協議。
② 設置 Connection 頭的值為 "Upgrade" 來指示這是一個升級請求。HTTP 協議提供了一種特殊的機制,這一機制允許將一個已建立的連接升級成新的、不相容的協議。
③ Upgrade 頭指定一項或多項協議名,按優先級排序,以逗號分隔。這里表示升級為 WebSocket 協議。
④ 簽名的鍵值驗證協議支持。
介紹完 WebSocket 的握手協議,接下來阿寶哥將使用 Node.js 來開發我們的 WebSocket 服務器。
3.2 實現握手功能
要開發一個 WebSocket 服務器,首先我們需要先實現握手功能,這里阿寶哥使用 Node.js 內置的 http 模塊來創建一個 HTTP 服務器,具體代碼如下所示:
const http = require("http");
const port = 8888;
const { generateAcceptValue } = require("./util");
const server = http.createServer((req, res) => {
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
res.end("大家好,我是阿寶哥。感謝你閱讀“你不知道的WebSocket”");
});
server.on("upgrade", function (req, socket) {
if (req.headers["upgrade"] !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
// 讀取客戶端提供的Sec-WebSocket-Key
const secWsKey = req.headers["sec-websocket-key"];
// 使用SHA-1算法生成Sec-WebSocket-Accept
const hash = generateAcceptValue(secWsKey);
// 設置HTTP響應頭
const responseHeaders = [
"HTTP/1.1 101 Web Socket Protocol Handshake",
"Upgrade: WebSocket",
"Connection: Upgrade",
`Sec-WebSocket-Accept: ${hash}`,
];
// 返回握手請求的響應信息
socket.write(responseHeaders.join("\r\n") + "\r\n\r\n");
});
server.listen(port, () =>
console.log(`Server running at http://localhost:${port}`)
);
在以上代碼中,我們首先引入了 http 模塊,然后通過調用該模塊的 createServer() 方法創建一個 HTTP 服務器,接著我們監聽 upgrade 事件,每次服務器響應升級請求時就會觸發該事件。由于我們的服務器只支持升級到 WebSocket 協議,所以如果客戶端請求升級的協議非 WebSocket 協議,我們將會返回 “400 Bad Request”。
當服務器接收到升級為 WebSocket 的握手請求時,會先從請求頭中獲取 “Sec-WebSocket-Key” 的值,然后把該值加上一個特殊字符串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然后計算 SHA-1 摘要,之后進行 Base64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。
上述的過程看起來好像有點繁瑣,其實利用 Node.js 內置的 crypto 模塊,幾行代碼就可以搞定了:
// util.js
const crypto = require("crypto");
const MAGIC_KEY = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
function generateAcceptValue(secWsKey) {
return crypto
.createHash("sha1")
.update(secWsKey + MAGIC_KEY, "utf8")
.digest("base64");
}
開發完握手功能之后,我們可以使用前面的示例來測試一下該功能。待服務器啟動之后,我們只要對 “發送普通文本” 示例,做簡單地調整,即把先前的 URL 地址替換成 ws://localhost:8888,就可以進行功能驗證。
感興趣的小伙們可以試試看,以下是阿寶哥本地運行后的結果:
從上圖可知,我們實現的握手功能已經可以正常工作了。那么握手有沒有可能失敗呢?答案是肯定的。比如網絡問題、服務器異?;?Sec-WebSocket-Accept 的值不正確。
下面阿寶哥修改一下 “Sec-WebSocket-Accept” 生成規則,比如修改 MAGIC_KEY 的值,然后重新驗證一下握手功能。此時,瀏覽器的控制臺會輸出以下異常信息:
WebSocket connection to 'ws://localhost:8888/' failed: Error during WebSocket handshake: Incorrect 'Sec-WebSocket-Accept' header value
如果你的 WebSocket 服務器要支持子協議的話,你可以參考以下代碼進行子協議的處理,阿寶哥就不繼續展開介紹了。
// 從請求頭中讀取子協議
const protocol = req.headers["sec-websocket-protocol"];
// 如果包含子協議,則解析子協議
const protocols = !protocol ? [] : protocol.split(",").map((s) => s.trim());
// 簡單起見,我們僅判斷是否含有JSON子協議
if (protocols.includes("json")) {
responseHeaders.push(`Sec-WebSocket-Protocol: json`);
}
好的,WebSocket 握手協議相關的內容基本已經介紹完了。下一步我們來介紹開發消息通信功能需要了解的一些基礎知識。
3.3 消息通信基礎
在 WebSocket 協議中,數據是通過一系列數據幀來進行傳輸的。為了避免由于網絡中介(例如一些攔截代理)或者一些安全問題,客戶端必須在它發送到服務器的所有幀中添加掩碼。服務端收到沒有添加掩碼的數據幀以后,必須立即關閉連接。
3.3.1 數據幀格式
要實現消息通信,我們就必須了解 WebSocket 數據幀的格式:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-------+-+-------------+-------------------------------+
|F|R|R|R| opcode|M| Payload len | Extended payload length |
|I|S|S|S| (4) |A| (7) | (16/64) |
|N|V|V|V| |S| | (if payload len==126/127) |
| |1|2|3| |K| | |
+-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - +
| Extended payload length continued, if payload len == 127 |
+ - - - - - - - - - - - - - - - +-------------------------------+
| |Masking-key, if MASK set to 1 |
+-------------------------------+-------------------------------+
| Masking-key (continued) | Payload Data |
+-------------------------------- - - - - - - - - - - - - - - - +
: Payload Data continued ... :
+ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +
| Payload Data continued ... |
+---------------------------------------------------------------+
可能有一些小伙伴看到上面的內容之后,就開始有點 “懵逼” 了。下面我們來結合實際的數據幀來進一步分析一下:
在上圖中,阿寶哥簡單分析了 “發送普通文本” 示例對應的數據幀格式。這里我們來進一步介紹一下 Payload length,因為在后面開發數據解析功能的時候,需要用到該知識點。
Payload length 表示以字節為單位的 “有效負載數據” 長度。它有以下幾種情形:
如果值為 0-125,那么就表示負載數據的長度。
如果是 126,那么接下來的 2 個字節解釋為 16 位的無符號整形作為負載數據的長度。
如果是 127,那么接下來的 8 個字節解釋為一個 64 位的無符號整形(最高位的 bit 必須為 0)作為負載數據的長度。
多字節長度量以網絡字節順序表示,有效負載長度是指 “擴展數據” + “應用數據” 的長度。“擴展數據” 的長度可能為 0,那么有效負載長度就是 “應用數據” 的長度。
另外,除非協商過擴展,否則 “擴展數據” 長度為 0 字節。在握手協議中,任何擴展都必須指定 “擴展數據” 的長度,這個長度如何進行計算,以及這個擴展如何使用。如果存在擴展,那么這個 “擴展數據” 包含在總的有效負載長度中。
3.3.2 掩碼算法
掩碼字段是一個由客戶端隨機選擇的 32 位的值。掩碼值必須是不可被預測的。因此,掩碼必須來自強大的熵源(entropy),并且給定的掩碼不能讓服務器或者代理能夠很容易的預測到后續幀。掩碼的不可預測性對于預防惡意應用的作者在網上暴露相關的字節數據至關重要。
掩碼不影響數據荷載的長度,對數據進行掩碼操作和對數據進行反掩碼操作所涉及的步驟是相同的。掩碼、反掩碼操作都采用如下算法:
j = i MOD 4
transformed-octet-i = original-octet-i XOR masking-key-octet-j
original-octet-i:為原始數據的第 i 字節。
transformed-octet-i:為轉換后的數據的第 i 字節。
masking-key-octet-j:為 mask key 第 j 字節。
為了讓小伙伴們能夠更好的理解上面掩碼的計算過程,我們來對示例中 “我是阿寶哥” 數據進行掩碼操作。這里 “我是阿寶哥” 對應的 UTF-8 編碼如下所示:
E6 88 91 E6 98 AF E9 98 BF E5 AE 9D E5 93 A5
而對應的 Masking-Key 為 0x08f6efb1,根據上面的算法,我們可以這樣進行掩碼運算:
let uint8 = new Uint8Array([0xE6, 0x88, 0x91, 0xE6, 0x98, 0xAF, 0xE9, 0x98,
0xBF, 0xE5, 0xAE, 0x9D, 0xE5, 0x93, 0xA5]);
let maskingKey = new Uint8Array([0x08, 0xf6, 0xef, 0xb1]);
let maskedUint8 = new Uint8Array(uint8.length);
for (let i = 0, j = 0; i < uint8.length; i++, j = i % 4) {
maskedUint8[i] = uint8[i] ^ maskingKey[j];
}
console.log(Array.from(maskedUint8).map(num=>Number(num).toString(16)).join(' '));
以上代碼成功運行后,控制臺會輸出以下結果:
ee 7e 7e 57 90 59 6 29 b7 13 41 2c ed 65 4a
上述結果與 WireShark 中的 Masked payload 對應的值是一致的,具體如下圖所示:
在 WebSocket 協議中,數據掩碼的作用是增強協議的安全性。但數據掩碼并不是為了保護數據本身,因為算法本身是公開的,運算也不復雜。那么為什么還要引入數據掩碼呢?引入數據掩碼是為了防止早期版本的協議中存在的代理緩存污染攻擊等問題。
了解完 WebSocket 掩碼算法和數據掩碼的作用之后,我們再來介紹一下數據分片的概念。
3.3.3 數據分片
WebSocket 的每條消息可能被切分成多個數據幀。當 WebSocket 的接收方收到一個數據幀時,會根據 FIN 的值來判斷,是否已經收到消息的最后一個數據幀。
利用 FIN 和 Opcode,我們就可以跨幀發送消息。操作碼告訴了幀應該做什么。如果是 0x1,有效載荷就是文本。如果是 0x2,有效載荷就是二進制數據。但是,如果是 0x0,則該幀是一個延續幀。這意味著服務器應該將幀的有效負載連接到從該客戶機接收到的最后一個幀。
為了讓大家能夠更好地理解上述的內容,我們來看一個來自 MDN 上的示例:
Client: FIN=1, opcode=0x1, msg="hello"
Server: (process complete message immediately) Hi.
Client: FIN=0, opcode=0x1, msg="and a"
Server: (listening, new message containing text started)
Client: FIN=0, opcode=0x0, msg="happy new"
Server: (listening, payload concatenated to previous message)
Client: FIN=1, opcode=0x0, msg="year!"
Server: (process complete message) Happy new year to you too!
在以上示例中,客戶端向服務器發送了兩條消息。第一個消息在單個幀中發送,而第二個消息跨三個幀發送。
其中第一個消息是一個完整的消息(FIN=1 且 opcode != 0x0),因此服務器可以根據需要進行處理或響應。而第二個消息是文本消息(opcode=0x1)且 FIN=0,表示消息還沒發送完成,還有后續的數據幀。該消息的所有剩余部分都用延續幀(opcode=0x0)發送,消息的最終幀用 FIN=1 標記。
好的,簡單介紹了數據分片的相關內容。接下來,我們來開始實現消息通信功能。
3.4 實現消息通信功能
阿寶哥把實現消息通信功能,分解為消息解析與消息響應兩個子功能,下面我們分別來介紹如何實現這兩個子功能。
3.4.1 消息解析
利用消息通信基礎環節中介紹的相關知識,阿寶哥實現了一個 parseMessage 函數,用來解析客戶端傳過來的 WebSocket 數據幀。出于簡單考慮,這里只處理文本幀,具體代碼如下所示:
function parseMessage(buffer) {
// 第一個字節,包含了FIN位,opcode, 掩碼位
const firstByte = buffer.readUInt8(0);
// [FIN, RSV, RSV, RSV, OPCODE, OPCODE, OPCODE, OPCODE];
// 右移7位取首位,1位,表示是否是最后一幀數據
const isFinalFrame = Boolean((firstByte >>> 7) & 0x01);
console.log("isFIN: ", isFinalFrame);
// 取出操作碼,低四位
/**
* %x0:表示一個延續幀。當 Opcode 為 0 時,表示本次數據傳輸采用了數據分片,當前收到的數據幀為其中一個數據分片;
* %x1:表示這是一個文本幀(text frame);
* %x2:表示這是一個二進制幀(binary frame);
* %x3-7:保留的操作代碼,用于后續定義的非控制幀;
* %x8:表示連接斷開;
* %x9:表示這是一個心跳請求(ping);
* %xA:表示這是一個心跳響應(pong);
* %xB-F:保留的操作代碼,用于后續定義的控制幀。
*/
const opcode = firstByte & 0x0f;
if (opcode === 0x08) {
// 連接關閉
return;
}
if (opcode === 0x02) {
// 二進制幀
return;
}
if (opcode === 0x01) {
// 目前只處理文本幀
let offset = 1;
const secondByte = buffer.readUInt8(offset);
// MASK: 1位,表示是否使用了掩碼,在發送給服務端的數據幀里必須使用掩碼,而服務端返回時不需要掩碼
const useMask = Boolean((secondByte >>> 7) & 0x01);
console.log("use MASK: ", useMask);
const payloadLen = secondByte & 0x7f; // 低7位表示載荷字節長度
offset += 1;
// 四個字節的掩碼
let MASK = [];
// 如果這個值在0-125之間,則后面的4個字節(32位)就應該被直接識別成掩碼;
if (payloadLen <= 0x7d) {
// 載荷長度小于125
MASK = buffer.slice(offset, 4 + offset);
offset += 4;
console.log("payload length: ", payloadLen);
} else if (payloadLen === 0x7e) {
// 如果這個值是126,則后面兩個字節(16位)內容應該,被識別成一個16位的二進制數表示數據內容大小;
console.log("payload length: ", buffer.readInt16BE(offset));
// 長度是126, 則后面兩個字節作為payload length,32位的掩碼
MASK = buffer.slice(offset + 2, offset + 2 + 4);
offset += 6;
} else {
// 如果這個值是127,則后面的8個字節(64位)內容應該被識別成一個64位的二進制數表示數據內容大小
MASK = buffer.slice(offset + 8, offset + 8 + 4);
offset += 12;
}
// 開始讀取后面的payload,與掩碼計算,得到原來的字節內容
const newBuffer = [];
const dataBuffer = buffer.slice(offset);
for (let i = 0, j = 0; i < dataBuffer.length; i++, j = i % 4) {
const nextBuf = dataBuffer[i];
newBuffer.push(nextBuf ^ MASK[j]);
}
return Buffer.from(newBuffer).toString();
}
return "";
}
創建完 parseMessage 函數,我們來更新一下之前創建的 WebSocket 服務器:
server.on("upgrade", function (req, socket) {
socket.on("data", (buffer) => {
const message = parseMessage(buffer);
if (message) {
console.log("Message from client:" + message);
} else if (message === null) {
console.log("WebSocket connection closed by the client.");
}
});
if (req.headers["upgrade"] !== "websocket") {
socket.end("HTTP/1.1 400 Bad Request");
return;
}
// 省略已有代碼
});
更新完成之后,我們重新啟動服務器,然后繼續使用 “發送普通文本” 的示例來測試消息解析功能。以下發送 “我是阿寶哥” 文本消息后,WebSocket 服務器輸出的信息。
Server running at http://localhost:8888
isFIN: true
use MASK: true
payload length: 15
Message from client:我是阿寶哥
通過觀察以上的輸出信息,我們的 WebSocket 服務器已經可以成功解析客戶端發送包含普通文本的數據幀,下一步我們來實現消息響應的功能。
3.4.2 消息響應
要把數據返回給客戶端,我們的 WebSocket 服務器也得按照 WebSocket 數據幀的格式來封裝數據。與前面介紹的 parseMessage 函數一樣,阿寶哥也封裝了一個 constructReply 函數用來封裝返回的數據,該函數的具體代碼如下:
function constructReply(data) {
const json = JSON.stringify(data);
const jsonByteLength = Buffer.byteLength(json);
// 目前只支持小于65535字節的負載
const lengthByteCount = jsonByteLength < 126 ? 0 : 2;
const payloadLength = lengthByteCount === 0 ? jsonByteLength : 126;
const buffer = Buffer.alloc(2 + lengthByteCount + jsonByteLength);
// 設置數據幀首字節,設置opcode為1,表示文本幀
buffer.writeUInt8(0b10000001, 0);
buffer.writeUInt8(payloadLength, 1);
// 如果payloadLength為126,則后面兩個字節(16位)內容應該,被識別成一個16位的二進制數表示數據內容大小
let payloadOffset = 2;
if (lengthByteCount > 0) {
buffer.writeUInt16BE(jsonByteLength, 2);
payloadOffset += lengthByteCount;
}
// 把JSON數據寫入到Buffer緩沖區中
buffer.write(json, payloadOffset);
return buffer;
}
創建完 constructReply 函數,我們再來更新一下之前創建的 WebSocket 服務器:
server.on("upgrade", function (req, socket) {
socket.on("data", (buffer) => {
const message = parseMessage(buffer);
if (message) {
console.log("Message from client:" + message);
// 新增以下
使你的代碼可讀和可維護的快速提示。
有多少次,你打開一個舊的項目,發現混亂的代碼,當你添加一些新的東西時,很容易崩潰?我們都有過這樣的經歷。
為了減少難以讀懂的javascript的數量,我提供了以下示例。這些都是我過去所犯過的錯誤。
對具有多個返回值的函數使用數組解構
假設我們有一個返回多個值的函數。一種可能的實現是使用數組解構,如下所示:
const func = () => {
const a = 1;
const b = 2;
const c = 3;
const d = 4;
return [a,b,c,d];
}
const [a,b,c,d] = func();
console.log(a,b,c,d); // 1,2,3,4
盡管上面的方法很好用,但確實引入了一些復雜性。
當我們調用函數并將值分配給 a,b,c,d 時,我們需要注意返回數據的順序。這里的一個小錯誤可能會成為調試的噩夢。
此外,無法確切指定我們要從函數中獲取哪些值,如果我們只需要 c 和 d 怎么辦?
相反,我們可以使用對象解構。
const func = () => {
const a = 1;
const b = 2;
const c = 3;
const d = 4;
return {a,b,c,d};
}
const {c,d} = func();
現在,我們可以輕松地從函數中選擇所需的數據,這也為我們的代碼提供了未來的保障,允許我們在不破壞東西的情況下增加額外的返回變量。
不對函數參數使用對象分解
假設我們有一個函數,該函數將一個對象作為參數并對該對象的屬性執行一些操作。一種幼稚的方法可能看起來像這樣:
// 不推薦
function getDaysRemaining(subscription) {
const startDate = subscription.startDate;
const endDate = subscription.endDate;
return endDate - startDate;
}
上面的方法按預期工作,但是,我們創建了兩個不必要的臨時引用 startDate 和 endDate。
一種更好的實現是對 subscription 對象使用對象解構來在一行中獲取 startDate 和 endDate。
// 推薦
function getDaysRemaining(subscription) {
const { startDate, endDate } = subscription;
return startDate - endDate;
}
我們可以更進一步,直接對參數執行對象析構。
// 更好
function getDaysRemaining({ startDate, endDate }) {
return startDate - endDate;
}
更優雅,不是嗎?
在不使用擴展運算符的情況下復制數組
使用 for循環遍歷數組并將其元素復制到新數組是冗長且相當丑陋的。
可以以簡潔明了的方式使用擴展運算符來達到相同的效果。
const stuff = [1,2,3];
// 不推薦
const stuffCopyBad = []
for(let i = 0; i < stuff.length; i++){
stuffCopyBad[i] = stuff[i];
}
// 推薦
const stuffCopyGood = [...stuff];
使用var
使用 const 保證不能重新分配變量。這樣可以減少我們代碼中的錯誤,并使其更易于理解。
// 不推薦
var x = "badX";
var y = "baxY";
// 推薦
const x = "goodX";
const y = "goodX";
果你確實需要重新分配變量,請始終選擇 let 而不是 var。
這是因為 let 是塊作用域的,而 var 是函數作用域的。
塊作用域告訴我們,只能在定義它的代碼塊內部訪問變量,嘗試訪問塊外部的變量會給我們提供ReferenceError。
for(let i = 0; i < 10; i++){
//something
}
print(i) // ReferenceError: i is not defined
函數作用域告訴我們,只能在定義其的函數內部訪問變量。
for(var i = 0; i < 10; i++){
//something
}
console.log(i) // 10
let 和 const 都是塊范圍的。
不使用模板字面值
手動將字符串連接在一起相當麻煩,而且輸入時可能會造成混淆。這是一個例子:
// 不推薦
function printStartAndEndDate({ startDate, endDate }) {
console.log('StartDate:' + startDate + ',EndDate:' + endDate)
}
模板文字為我們提供了一種可讀且簡潔的語法,該語法支持字符串插值。
// 推薦
function printStartAndEndDate({ startDate, endDate }) {
console.log(`StartDate: ${startDate}, EndDate: ${endDate}`)
}
模板文字也提供了嵌入新行的簡便方法,你所需要做的就是照常按鍵盤上的Enter鍵。
// 兩行打印
function printStartAndEndDate({ startDate, endDate }) {
console.log(`StartDate: ${startDate}
EndDate: ${endDate}`)
}
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
沒有 switch 就沒有復雜的代碼塊
switch很方便:給定一個表達式,我們可以檢查它是否與一堆case子句中的其他表達式匹配。 考慮以下示例:
const name = "Juliana";
switch (name) {
case "Juliana":
console.log("She's Juliana");
break;
case "Tom":
console.log("She's not Juliana");
break;
}
當 name 為“Juliana”時,我們將打印一條消息,并立即中斷退出該塊。 在switch函數內部時,直接在 case 塊使用 return,就可以省略break。
當沒有匹配項時,可以使用 default 選項:
const name = "Kris";
switch (name) {
case "Juliana":
console.log("She's Juliana");
break;
case "Tom":
console.log("She's not Juliana");
break;
default:
console.log("Sorry, no match");
}
switch在 Redux reducers 中也大量使用(盡管Redux Toolkit簡化了樣板),以避免產生大量的if。 考慮以下示例:
const LOGIN_SUCCESS = "LOGIN_SUCCESS";
const LOGIN_FAILED = "LOGIN_FAILED";
const authState = {
token: "",
error: "",
};
function authReducer(state = authState, action) {
switch (action.type) {
case LOGIN_SUCCESS:
return { ...state, token: action.payload };
case LOGIN_FAILED:
return { ...state, error: action.payload };
default:
return state;
}
}
這有什么問題嗎?幾乎沒有。但是有沒有更好的選擇呢?
從 Python 獲得的啟示
來自 Telmo 的這條 Tweet引起了我的注意。 他展示了兩種“switch”風格,其中一種非常接近Python中的模式。
Python 沒有開關,它給我們一個更好的替代方法。 首先讓我們將代碼從 JavaScript 移植到Python:
LOGIN_SUCCESS = "LOGIN_SUCCESS"
LOGIN_FAILED = "LOGIN_FAILED"
auth_state = {"token": "", "error": ""}
def auth_reducer(state=auth_state, action={}):
mapping = {
LOGIN_SUCCESS: {**state, "token": action["payload"]},
LOGIN_FAILED: {**state, "error": action["payload"]},
}
return mapping.get(action["type"], state)
在 Python 中,我們可以使用字典來模擬switch 。 dict.get() 可以用來表示 switch 的 default 語句。
當訪問不存在的key時,Python 會觸發一個 KeyError 錯誤:
>>> my_dict = {
"name": "John",
"city": "Rome",
"age": 44
}
>>> my_dict["not_here"]
# Output: KeyError: 'not_here'
.get()方法是一種更安全方法,因為它不會引發錯誤,并且可以為不存在的key指定默認值:
>>> my_dict = {
"name": "John",
"city": "Rome",
"age": 44
}
>>> my_dict.get("not_here", "not found")
# Output: 'not found'
因此,Pytho n中的這一行:
return mapping.get(action["type"], state)
等價于 JavaScript中的:
function authReducer(state = authState, action) {
...
default:
return state;
...
}
使用字典的方式替換 switch
再次思考前面的示例:
const LOGIN_SUCCESS = "LOGIN_SUCCESS";
const LOGIN_FAILED = "LOGIN_FAILED";
const authState = {
token: "",
error: "",
};
function authReducer(state = authState, action) {
switch (action.type) {
case LOGIN_SUCCESS:
return { ...state, token: action.payload };
case LOGIN_FAILED:
return { ...state, error: action.payload };
default:
return state;
}
}
如果不使用 switch 我們可以這樣做:
function authReducer(state = authState, action) {
const mapping = {
[LOGIN_SUCCESS]: { ...state, token: action.payload },
[LOGIN_FAILED]: { ...state, error: action.payload }
};
return mapping[action.type] || state;
}
這里我們使用 ES6 中的計算屬性,此處,mapping的屬性是根據兩個常量即時計算的:LOGIN_SUCCESS 和 LOGIN_FAILED。
屬性對應的值,我們這里使用的是對象解構,這里 ES9((ECMAScript 2018)) 出來的。
const mapping = {
[LOGIN_SUCCESS]: { ...state, token: action.payload },
[LOGIN_FAILED]: { ...state, error: action.payload }
}
你如何看待這種方法?它對 switch 來說可能還能一些限制,但對于 reducer 來說可能是一種更好的方案。
但是,此代碼的性能如何?
性能怎么樣?
switch 的性能優于字典的寫法。我們可以使用下面的事例測試一下:
console.time("sample");
for (let i = 0; i < 2000000; i++) {
const nextState = authReducer(authState, {
type: LOGIN_SUCCESS,
payload: "some_token"
});
}
console.timeEnd("sample");
測量它們十次左右,
for t in {1..10}; do node switch.js >> switch.txt;done
for t in {1..10}; do node map.js >> map.txt;done
clipboard.png
人才們的 【三連】 就是小智不斷分享的最大動力,如果本篇博客有任何錯誤和建議,歡迎人才們留言,最后,謝謝大家的觀看。
原文:https://codeburst.io/alternat...
代碼部署后可能存在的BUG沒法實時知道,事后為了解決這些BUG,花了大量的時間進行log 調試,這邊順便給大家推薦一個好用的BUG監控工具 Fundebug。
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
Vuex 是一個專為 Vue.js 應用程序開發的狀態管理模式。它采用集中式存儲管理應用的所有組件的狀態,并以相應的規則保證狀態以一種可預測的方式發生變化。Vuex 也集成到 Vue 的官方調試工具 devtools extension,提供了諸如零配置的 time-travel 調試、狀態快照導入導出等高級調試功能。采用了全局單例模式,將組件的共享狀態抽離出來管理,使得組件樹中每一個位置都可以獲取共享的狀態或者觸發行為。
那么什么是狀態呢?我把狀態理解為在沒有使用vuex時,在當前組件中data內需要共用的數據為狀態。
vuex使得狀態或行為成為了共享的狀態,所共享的狀態或行為可以在各個組件中都可以訪問到,省去了子父或子子之間傳遞變量,提高了開發效率。
當我們不使用vuex時,對于組件之間傳遞信息會較為麻煩。
App.vue文件中:
<template>
<div id="app">
<Fruits :fruitList="fruitList"/>
</div>
</template>
<script> import Goods from './components/Goods'; export default { name: 'App',
components:{
Fruits,
Goods
}, data(){
return{ goodList:[
{
name:'doll',
price:12 },
{ name:'glass',
price:10 }
],
}
}
}
</script>
<style>
</style>
Good.vue文件中:
<template>
<div class="hello">
<ul>
<li v-for="(good,index) in goodList" :key="index"> name:{{good.name}} number: {{good.number}} {{index}}
</li>
</ul>
</div>
</template>
<script> export default { props:['goodList'],
}
</script>
<style>
</style>
首先先創建一個js文件作為兩兄弟之間傳輸的紐扣,這里起名為msg.js
//創建并暴露vue import Vue from 'vue';
export default new Vue
兄弟組件Goods:
<template>
<div>
<button @click="deliver">點擊</button>
</div>
</template>
<script> import MSG from '../msg';
export default {
data(){ return{
msg:'hahah' }
},
methods:{
deliver() {
MSG.$emit('showMsg',this.msg)
}
}
}
</script>
<style>
</style>
兄弟組件Fruits:
<template>
<div>
<div class="fruit">
{{text}}
</div>
</div>
</template>
<script> import MSG from '../msg';
export default {
data(){ return{
text:'' }
},
created(){ this.getMsg()
},
methods:{
getMsg(){
MSG.$on('showMsg',(message)=>{ this.text = message
})
}
}
}
</script>
<style>
</style>
在App組件中的代碼:
點擊按鈕:
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
凡是要知其然知其所以然
文件上傳相信很多朋友都有遇到過,那或許你也遇到過當上傳大文件時,上傳時間較長,且經常失敗的困擾,并且失敗后,又得重新上傳很是煩人。那我們先了解下失敗的原因吧!
據我了解大概有以下原因:
服務器配置:例如在PHP中默認的文件上傳大小為8M【post_max_size = 8m】,若你在一個請求體中放入8M以上的內容時,便會出現異常
請求超時:當你設置了接口的超時時間為10s,那么上傳大文件時,一個接口響應時間超過10s,那么便會被Faild掉。
網絡波動:這個就屬于不可控因素,也是較常見的問題。
基于以上原因,聰明的人們就想到了,將文件拆分多個小文件,依次上傳,不就解決以上1,2問題嘛,這便是分片上傳。 網絡波動這個實在不可控,也許一陣大風刮來,就斷網了呢。那這樣好了,既然斷網無法控制,那我可以控制只上傳已經上傳的文件內容,不就好了,這樣大大加快了重新上傳的速度。所以便有了“斷點續傳”一說。此時,人群中有人插了一嘴,有些文件我已經上傳一遍了,為啥還要在上傳,能不能不浪費我流量和時間。喔...這個嘛,簡單,每次上傳時判斷下是否存在這個文件,若存在就不重新上傳便可,于是又有了“秒傳”一說。從此這"三兄弟" 便自行CP,統治了整個文件界?!?
注意文中的代碼并非實際代碼,請移步至github查看代碼
https://github.com/pseudo-god...
分片上傳
HTML
原生INPUT樣式較丑,這里通過樣式疊加的方式,放一個Button.
<div class="btns">
<el-button-group>
<el-button :disabled="changeDisabled">
<i class="el-icon-upload2 el-icon--left" size="mini"></i>選擇文件
<input
v-if="!changeDisabled"
type="file"
:multiple="multiple"
class="select-file-input"
:accept="accept"
@change="handleFileChange"
/>
</el-button>
<el-button :disabled="uploadDisabled" @click="handleUpload()"><i class="el-icon-upload el-icon--left" size="mini"></i>上傳</el-button>
<el-button :disabled="pauseDisabled" @click="handlePause"><i class="el-icon-video-pause el-icon--left" size="mini"></i>暫停</el-button>
<el-button :disabled="resumeDisabled" @click="handleResume"><i class="el-icon-video-play el-icon--left" size="mini"></i>恢復</el-button>
<el-button :disabled="clearDisabled" @click="clearFiles"><i class="el-icon-video-play el-icon--left" size="mini"></i>清空</el-button>
</el-button-group>
<slot
//data 數據
var chunkSize = 10 * 1024 * 1024; // 切片大小
var fileIndex = 0; // 當前正在被遍歷的文件下標
data: () => ({
container: {
files: null
},
tempFilesArr: [], // 存儲files信息
cancels: [], // 存儲要取消的請求
tempThreads: 3,
// 默認狀態
status: Status.wait
}),
一個稍微好看的UI就出來了。
選擇文件
選擇文件過程中,需要對外暴露出幾個鉤子,熟悉elementUi的同學應該很眼熟,這幾個鉤子基本與其一致。onExceed:文件超出個數限制時的鉤子、beforeUpload:文件上傳之前
fileIndex 這個很重要,因為是多文件上傳,所以定位當前正在被上傳的文件就很重要,基本都靠它
handleFileChange(e) {
const files = e.target.files;
if (!files) return;
Object.assign(this.$data, this.$options.data()); // 重置data所有數據
fileIndex = 0; // 重置文件下標
this.container.files = files;
// 判斷文件選擇的個數
if (this.limit && this.container.files.length > this.limit) {
this.onExceed && this.onExceed(files);
return;
}
// 因filelist不可編輯,故拷貝filelist 對象
var index = 0; // 所選文件的下標,主要用于剔除文件后,原文件list與臨時文件list不對應的情況
for (const key in this.container.files) {
if (this.container.files.hasOwnProperty(key)) {
const file = this.container.files[key];
if (this.beforeUpload) {
const before = this.beforeUpload(file);
if (before) {
this.pushTempFile(file, index);
}
}
if (!this.beforeUpload) {
this.pushTempFile(file, index);
}
index++;
}
}
},
// 存入 tempFilesArr,為了上面的鉤子,所以將代碼做了拆分
pushTempFile(file, index) {
// 額外的初始值
const obj = {
status: fileStatus.wait,
chunkList: [],
uploadProgress: 0,
hashProgress: 0,
index
};
for (const k in file) {
obj[k] = file[k];
}
console.log('pushTempFile -> obj', obj);
this.tempFilesArr.push(obj);
}
分片上傳
創建切片,循環分解文件即可
createFileChunk(file, size = chunkSize) {
const fileChunkList = [];
var count = 0;
while (count < file.size) {
fileChunkList.push({
file: file.slice(count, count + size)
});
count += size;
}
return fileChunkList;
}
循環創建切片,既然咱們做的是多文件,所以這里就有循環去處理,依次創建文件切片,及切片的上傳。
async handleUpload(resume) {
if (!this.container.files) return;
this.status = Status.uploading;
const filesArr = this.container.files;
var tempFilesArr = this.tempFilesArr;
for (let i = 0; i < tempFilesArr.length; i++) {
fileIndex = i;
//創建切片
const fileChunkList = this.createFileChunk(
filesArr[tempFilesArr[i].index]
);
tempFilesArr[i].fileHash ='xxxx'; // 先不用看這個,后面會講,占個位置
tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
fileHash: tempFilesArr[i].hash,
fileName: tempFilesArr[i].name,
index,
hash: tempFilesArr[i].hash + '-' + index,
chunk: file,
size: file.size,
uploaded: false,
progress: 0, // 每個塊的上傳進度
status: 'wait' // 上傳狀態,用作進度狀態顯示
}));
//上傳切片
await this.uploadChunks(this.tempFilesArr[i]);
}
}
上傳切片,這個里需要考慮的問題較多,也算是核心吧,uploadChunks方法只負責構造傳遞給后端的數據,核心上傳功能放到sendRequest方法中
async uploadChunks(data) {
var chunkData = data.chunkList;
const requestDataList = chunkData
.map(({ fileHash, chunk, fileName, index }) => {
const formData = new FormData();
formData.append('md5', fileHash);
formData.append('file', chunk);
formData.append('fileName', index); // 文件名使用切片的下標
return { formData, index, fileName };
});
try {
await this.sendRequest(requestDataList, chunkData);
} catch (error) {
// 上傳有被reject的
this.$message.error('親 上傳失敗了,考慮重試下呦' + error);
return;
}
// 合并切片
const isUpload = chunkData.some(item => item.uploaded === false);
console.log('created -> isUpload', isUpload);
if (isUpload) {
alert('存在失敗的切片');
} else {
// 執行合并
await this.mergeRequest(data);
}
}
sendReques。上傳這是最重要的地方,也是容易失敗的地方,假設有10個分片,那我們若是直接發10個請求的話,很容易達到瀏覽器的瓶頸,所以需要對請求進行并發處理。
并發處理:這里我使用for循環控制并發的初始并發數,然后在 handler 函數里調用自己,這樣就控制了并發。在handler中,通過數組API.shift模擬隊列的效果,來上傳切片。
重試: retryArr 數組存儲每個切片文件請求的重試次數,做累加。比如[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次。為保證能與文件做對應,const index = formInfo.index; 我們直接從數據中拿之前定義好的index。 若失敗后,將失敗的請求重新加入隊列即可。
關于并發及重試我寫了一個小Demo,若不理解可以自己在研究下,文件地址:https://github.com/pseudo-god... , 重試代碼好像被我弄丟了,大家要是有需求,我再補吧!
// 并發處理
sendRequest(forms, chunkData) {
var finished = 0;
const total = forms.length;
const that = this;
const retryArr = []; // 數組存儲每個文件hash請求的重試次數,做累加 比如[1,0,2],就是第0個文件切片報錯1次,第2個報錯2次
return new Promise((resolve, reject) => {
const handler = () => {
if (forms.length) {
// 出棧
const formInfo = forms.shift();
const formData = formInfo.formData;
const index = formInfo.index;
instance.post('fileChunk', formData, {
onUploadProgress: that.createProgresshandler(chunkData[index]),
cancelToken: new CancelToken(c => this.cancels.push(c)),
timeout: 0
}).then(res => {
console.log('handler -> res', res);
// 更改狀態
chunkData[index].uploaded = true;
chunkData[index].status = 'success';
finished++;
handler();
})
.catch(e => {
// 若暫停,則禁止重試
if (this.status === Status.pause) return;
if (typeof retryArr[index] !== 'number') {
retryArr[index] = 0;
}
// 更新狀態
chunkData[index].status = 'warning';
// 累加錯誤次數
retryArr[index]++;
// 重試3次
if (retryArr[index] >= this.chunkRetry) {
return reject('重試失敗', retryArr);
}
this.tempThreads++; // 釋放當前占用的通道
// 將失敗的重新加入隊列
forms.push(formInfo);
handler();
});
}
if (finished >= total) {
resolve('done');
}
};
// 控制并發
for (let i = 0; i < this.tempThreads; i++) {
handler();
}
});
}
切片的上傳進度,通過axios的onUploadProgress事件,結合createProgresshandler方法進行維護
// 切片上傳進度
createProgresshandler(item) {
return p => {
item.progress = parseInt(String((p.loaded / p.total) * 100));
this.fileProgress();
};
}
Hash計算
其實就是算一個文件的MD5值,MD5在整個項目中用到的地方也就幾點。
秒傳,需要通過MD5值判斷文件是否已存在。
續傳:需要用到MD5作為key值,當唯一值使用。
本項目主要使用worker處理,性能及速度都會有很大提升.
由于是多文件,所以HASH的計算進度也要體現在每個文件上,所以這里使用全局變量fileIndex來定位當前正在被上傳的文件
執行計算hash
正在上傳文件
// 生成文件 hash(web-worker)
calculateHash(fileChunkList) {
return new Promise(resolve => {
this.container.worker = new Worker('./hash.js');
this.container.worker.postMessage({ fileChunkList });
this.container.worker.onmessage = e => {
const { percentage, hash } = e.data;
if (this.tempFilesArr[fileIndex]) {
this.tempFilesArr[fileIndex].hashProgress = Number(
percentage.toFixed(0)
);
}
if (hash) {
resolve(hash);
}
};
});
}
因使用worker,所以我們不能直接使用NPM包方式使用MD5。需要單獨去下載spark-md5.js文件,并引入
//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);
};
文件合并
當我們的切片全部上傳完畢后,就需要進行文件的合并,這里我們只需要請求接口即可
mergeRequest(data) {
const obj = {
md5: data.fileHash,
fileName: data.name,
fileChunkNum: data.chunkList.length
};
instance.post('fileChunk/merge', obj,
{
timeout: 0
})
.then((res) => {
this.$message.success('上傳成功');
});
}
Done: 至此一個分片上傳的功能便已完成
斷點續傳
顧名思義,就是從那斷的就從那開始,明確思路就很簡單了。一般有2種方式,一種為服務器端返回,告知我從那開始,還有一種是瀏覽器端自行處理。2種方案各有優缺點。本項目使用第二種。
思路:已文件HASH為key值,每個切片上傳成功后,記錄下來便可。若需要續傳時,直接跳過記錄中已存在的便可。本項目將使用Localstorage進行存儲,這里我已提前封裝好addChunkStorage、getChunkStorage方法。
存儲在Stroage的數據
緩存處理
在切片上傳的axios成功回調中,存儲已上傳成功的切片
instance.post('fileChunk', formData, )
.then(res => {
// 存儲已上傳的切片下標
+ this.addChunkStorage(chunkData[index].fileHash, index);
handler();
})
在切片上傳前,先看下localstorage中是否存在已上傳的切片,并修改uploaded
async handleUpload(resume) {
+ const getChunkStorage = this.getChunkStorage(tempFilesArr[i].hash);
tempFilesArr[i].chunkList = fileChunkList.map(({ file }, index) => ({
+ uploaded: getChunkStorage && getChunkStorage.includes(index), // 標識:是否已完成上傳
+ progress: getChunkStorage && getChunkStorage.includes(index) ? 100 : 0,
+ status: getChunkStorage && getChunkStorage.includes(index)? 'success'
+ : 'wait' // 上傳狀態,用作進度狀態顯示
}));
}
構造切片數據時,過濾掉uploaded為true的
async uploadChunks(data) {
var chunkData = data.chunkList;
const requestDataList = chunkData
+ .filter(({ uploaded }) => !uploaded)
.map(({ fileHash, chunk, fileName, index }) => {
const formData = new FormData();
formData.append('md5', fileHash);
formData.append('file', chunk);
formData.append('fileName', index); // 文件名使用切片的下標
return { formData, index, fileName };
})
}
垃圾文件清理
隨著上傳文件的增多,相應的垃圾文件也會增多,比如有些時候上傳一半就不再繼續,或上傳失敗,碎片文件就會增多。解決方案我目前想了2種
前端在localstorage設置緩存時間,超過時間就發送請求通知后端清理碎片文件,同時前端也要清理緩存。
前后端都約定好,每個緩存從生成開始,只能存儲12小時,12小時后自動清理
以上2中方案似乎都有點問題,極有可能造成前后端因時間差,引發切片上傳異常的問題,后面想到合適的解決方案再來更新吧。
Done: 續傳到這里也就完成了。
秒傳
這算是最簡單的,只是聽起來很厲害的樣子。原理:計算整個文件的HASH,在執行上傳操作前,向服務端發送請求,傳遞MD5值,后端進行文件檢索。若服務器中已存在該文件,便不進行后續的任何操作,上傳也便直接結束。大家一看就明白
async handleUpload(resume) {
if (!this.container.files) return;
const filesArr = this.container.files;
var tempFilesArr = this.tempFilesArr;
for (let i = 0; i < tempFilesArr.length; i++) {
const fileChunkList = this.createFileChunk(
filesArr[tempFilesArr[i].index]
);
// hash校驗,是否為秒傳
+ tempFilesArr[i].hash = await this.calculateHash(fileChunkList);
+ const verifyRes = await this.verifyUpload(
+ tempFilesArr[i].name,
+ tempFilesArr[i].hash
+ );
+ if (verifyRes.data.presence) {
+ tempFilesArr[i].status = fileStatus.secondPass;
+ tempFilesArr[i].uploadProgress = 100;
+ } else {
console.log('開始上傳切片文件----》', tempFilesArr[i].name);
await this.uploadChunks(this.tempFilesArr[i]);
}
}
}
// 文件上傳之前的校驗: 校驗文件是否已存在
verifyUpload(fileName, fileHash) {
return new Promise(resolve => {
const obj = {
md5: fileHash,
fileName,
...this.uploadArguments //傳遞其他參數
};
instance
.post('fileChunk/presence', obj)
.then(res => {
resolve(res.data);
})
.catch(err => {
console.log('verifyUpload -> err', err);
});
});
}
Done: 秒傳到這里也就完成了。
后端處理
文章好像有點長了,具體代碼邏輯就先不貼了,除非有人留言要求,嘻嘻,有時間再更新
Node版
請前往 https://github.com/pseudo-god... 查看
JAVA版
下周應該會更新處理
PHP版
1年多沒寫PHP了,抽空我會慢慢補上來
待完善
切片的大?。哼@個后面會做出動態計算的。需要根據當前所上傳文件的大小,自動計算合適的切片大小。避免出現切片過多的情況。
文件追加:目前上傳文件過程中,不能繼續選擇文件加入隊列。(這個沒想好應該怎么處理。)
更新記錄
組件已經運行一段時間了,期間也測試出幾個問題,本來以為沒BUG的,看起來BUG都挺嚴重
BUG-1:當同時上傳多個內容相同但是文件名稱不同的文件時,出現上傳失敗的問題。
預期結果:第一個上傳成功后,后面相同的問文件應該直接秒傳
實際結果:第一個上傳成功后,其余相同的文件都失敗,錯誤信息,塊數不對。
原因:當第一個文件塊上傳完畢后,便立即進行了下一個文件的循環,導致無法及時獲取文件是否已秒傳的狀態,從而導致失敗。
解決方案:在當前文件分片上傳完畢并且請求合并接口完畢后,再進行下一次循環。
將子方法都改為同步方式,mergeRequest 和 uploadChunks 方法
BUG-2: 當每次選擇相同的文件并觸發beforeUpload方法時,若第二次也選擇了相同的文件,beforeUpload方法失效,從而導致整個流程失效。
原因:之前每次選擇文件時,沒有清空上次所選input文件的數據,相同數據的情況下,是不會觸發input的change事件。
解決方案:每次點擊input時,清空數據即可。我順帶優化了下其他的代碼,具體看提交記錄吧。
<input
v-if="!changeDisabled"
type="file"
:multiple="multiple"
class="select-file-input"
:accept="accept"
+ οnclick="f.outerHTML=f.outerHTML"
@change="handleFileChange"/>
重寫了暫停和恢復的功能,實際上,主要是增加了暫停和恢復的狀態
之前的處理邏輯太簡單粗暴,存在諸多問題?,F在將狀態定位在每一個文件之上,這樣恢復上傳時,直接跳過即可
封裝組件
寫了一大堆,其實以上代碼你直接復制也無法使用,這里我將此封裝了一個組件。大家可以去github下載文件,里面有使用案例 ,若有用記得隨手給個star,謝謝!
偷個懶,具體封裝組件的代碼就不列出來了,大家直接去下載文件查看,若有不明白的,可留言。
組件文檔
Attribute
參數 類型 說明 默認 備注
headers Object 設置請求頭
before-upload Function 上傳文件前的鉤子,返回false則停止上傳
accept String 接受上傳的文件類型
upload-arguments Object 上傳文件時攜帶的參數
with-credentials Boolean 是否傳遞Cookie false
limit Number 最大允許上傳個數 0 0為不限制
on-exceed Function 文件超出個數限制時的鉤子
multiple Boolean 是否為多選模式 true
base-url String 由于本組件為內置的AXIOS,若你需要走代理,可以直接在這里配置你的基礎路徑
chunk-size Number 每個切片的大小 10M
threads Number 請求的并發數 3 并發數越高,對服務器的性能要求越高,盡可能用默認值即可
chunk-retry Number 錯誤重試次數 3 分片請求的錯誤重試次數
Slot
方法名 說明 參數 備注
header 按鈕區域 無
tip 提示說明文字 無
后端接口文檔:按文檔實現即可
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
我們應該學習 webpack 嗎 ?
如今,CLI工具(如create-react-app或Vue -cli)已經為我們抽象了大部分配置,并提供了合理的默認設置。
即使那樣,了解幕后工作原理還是有好處的,因為我們遲早需要對默認值進行一些調整。
在本文中中,我們會知道 webpack可以做什么,以及如何配置它以滿足我們的日常需求。
什么是 webpack?
作為前端開發人員,我們應該熟悉 module 概念。 你可能聽說過 AMD模塊,UMD,Common JS還有ES模塊。
webpack是一個模塊綁定器,它對模塊有一個更廣泛的定義,對于webpack來說,模塊是:
Common JS modules
AMD modules
CSS import
Images url
ES modules
webpack 還可以從這些模塊中獲取依賴關系。
webpack 的最終目標是將所有這些不同的源和模塊類型統一起來,從而將所有內容導入JavaScript代碼,并最生成可以運行的代碼。
entry
Webpack的 entry(入口點)是收集前端項目的所有依賴項的起點。 實際上,這是一個簡單的 JavaScript 文件。
這些依賴關系形成一個依賴關系圖。
Webpack 的默認入口點(從版本4開始)是src/index.js,它是可配置的。 webpack 可以有多個入口點。
Output
output是生成的JavaScript和靜態文件的地方。
Loaders
Loaders 是第三方擴展程序,可幫助webpack處理各種文件擴展名。 例如,CSS,圖像或txt文件。
Loaders的目標是在模塊中轉換文件(JavaScript以外的文件)。 文件成為模塊后,webpack可以將其用作項目中的依賴項。
Plugins
插件是第三方擴展,可以更改webpack的工作方式。 例如,有一些用于提取HTML,CSS或設置環境變量的插件。
Mode
webpack 有兩種操作模式:開發(development)和生產(production)。 它們之間的主要區別是生產模式自動生成一些優化后的代碼。
Code splitting
代碼拆分或延遲加載是一種避免生成較大包的優化技術。
通過代碼拆分,開發人員可以決定僅在響應某些用戶交互時加載整個JavaScript塊,比如單擊或路由更改(或其他條件)。
被拆分的一段代碼稱為 chunk。
Webpack入門
開始使用webpack時,先創建一個新文件夾,然后進入該文件中,初始化一個NPM項目,如下所示:
mkdir webpack-tutorial && cd $_
npm init -y
接著安裝 webpack,webpack-cli和 webpack-dev-server:
npm i webpack webpack-cli webpack-dev-server --save-dev
要運行 webpack,只需要在 package.json 配置如下命令即可:
"scripts": {
"dev": "webpack --mode development"
},
通過這個腳本,我們指導webpack在開發模式下工作,方便在本地工作。
Webpack 的第一步
在開發模式下運行 webpack:
npm run dev
運行完后會看到如下錯誤:
ERROR in Entry module not found: Error: Can't resolve './src'
webpack 在這里尋找默認入口點src/index.js,所以我們需要手動創建一下,并輸入一些內容:
mkdir src
echo 'console.log("Hello webpack!")' > src/index.js
現在再次運行npm run dev,錯誤就沒有了。 運行的結果生成了一個名為dist/的新文件夾,其中包含一個名為main.js的 JS 文件:
dist
└── main.js
這是我們的第一個webpack包,也稱為output。
配置 Webpack
對于簡單的任務,webpack無需配置即可工作,但是很快我們就會遇到問題,一些文件如果沒有指定的 loader 是沒法打包的。所以,我們需要對 webpack進行配置,對于 webpack 的配置是在 webpack.config.js 進行的,所以我們需要創建該文件:
touch webpack.config.js
Webpack 用 JavaScript 編寫,并在無頭 JS 環境(例如Node.js)上運行。 在此文件中,至少需要一個module.exports,這是的 Common JS 導出方式:
module.exports = {
//
};
在webpack.config.js中,我們可以通過添加或修改來改變webpack的行為方式
entry point
output
loaders
plugins
code splitting
例如,要更改入口路徑,我們可以這樣做
const path = require("path");
module.exports = {
entry: { index: path.resolve(__dirname, "source", "index.js") }
};
現在,webpack 將在source/index.js中查找要加載的第一個文件。 要更改包的輸出路徑,我們可以這樣做:
const path = require("path");
module.exports = {
output: {
path: path.resolve(__dirname, "build")
}
}
這樣,webpack將把最終生成包放在build中,而不是dist.(為了簡單起見,在本文中,我們使用默認配置)。
打包 HTML
沒有HTML頁面的Web應用程序幾乎沒有用。 要在webpack中使用 HTML,我們需要安裝一個插件html-webpack-plugin:
npm i html-webpack-plugin --save-dev
一旦插件安裝好,我們就可以對其進行配置:
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src", "index.html")
})
]
};
這里的意思是讓 webpack,從 src/index.html 加載 HTML 模板。
html-webpack-plugin的最終目標有兩個:
加載 html 文件
它將bundle注入到同一個文件中
接著,我們需要在 src/index.html 中創建一個簡單的 HTML 文件:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Webpack tutorial</title>
</head>
<body>
</body>
</html>
稍后,我們會運行這個程序。
webpack development server
在本文第一部分中,我們安裝了webpack-dev-server。如果你忘記安裝了,現在可以運行下面命令安裝一下:
npm i webpack-dev-server --save-dev
webpack-dev-server 可以讓開發更方便,不需要改動了文件就去手動刷新文件。 配置完成后,我們可以啟動本地服務器來提供文件。
要配置webpack-dev-server,請打開package.json并添加一個 “start” 命令:
"scripts": {
"dev": "webpack --mode development",
"start": "webpack-dev-server --mode development --open",
},
有了 start 命令,我們來跑一下:
npm start
運行后,默認瀏覽器應打開。 在瀏覽器的控制臺中,還應該看到一個 script 標簽,引入的是我們的 main.js。
clipboard.png
使用 webpack loader
Loader是第三方擴展程序,可幫助webpack處理各種文件擴展名。 例如,有用于 CSS,圖像或 txt 文件的加載程序。
下面是一些 loader 配置介紹:
module.exports = {
module: {
rules: [
{
test: /\.filename$/,
use: ["loader-b", "loader-a"]
}
]
},
//
};
相關配置以module 關鍵字開始。 在module內,我們在rules內配置每個加載程序組或單個加載程序。
對于我們想要作為模塊處理的每個文件,我們用test和use配置一個對象
{
test: /\.filename$/,
use: ["loader-b", "loader-a"]
}
test 告訴 webpack “嘿,將此文件名視為一個模塊”。 use 定義將哪些 loaders 應用于些打包的文件。
打包 CSS
要 在webpack 中打包CSS,我們需要至少安裝兩個 loader。Loader 對于幫助 webpack 了解如何處理.css文件是必不可少的。
要在 webpack 中測試 CSS,我們需要在 src 下創建一個style.css文件:
h1 {
color: orange;
}
另外在 src/index.html 添加 h1 標簽
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Webpack tutorial</title>
</head>
<body>
<h1>Hello webpack!</h1>
</body>
</html>
最后,在src/index.js 中加載 CSS:
在測試之前,我們需要安裝兩個 loader:
css-loader: 解析 css 代碼中的 url、@import語法像import和require一樣去處理css里面引入的模塊
style-loader:幫我們直接將css-loader解析后的內容掛載到html頁面當中
安裝 loader:
npm i css-loader style-loader --save-dev
然后在webpack.config.js中配置它們
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src", "index.html")
})
]
};
現在,如果你運行npm start,會看到樣式表加載在HTML的頭部:
clipboard.png
一旦CSS Loader 就位,我們還可以使用MiniCssExtractPlugin提取CSS文件
Webpack Loader 順序很重要!
在webpack中,Loader 在配置中出現的順序非常重要。以下配置無效:
//
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ["css-loader", "style-loader"]
}
]
},
//
};
此處,“style-loader”出現在 “css-loader” 之前。 但是style-loader用于在頁面中注入樣式,而不是用于加載實際的CSS文件。
相反,以下配置有效:
module.exports = {
module: {
rules: [
{
test: /\.css$/,
use: ["style-loader", "css-loader"]
}
]
},
//
};
webpack loaders 是從右到左執行的。
打包 sass
要在 webpack 中測試sass,同樣,我們需要在 src 目錄下創建一個 style.scss 文件:
@import url("https://fonts.googleapis.com/css?family=Karla:weight@400;700&display=swap");
$font: "Karla", sans-serif;
$primary-color: #3e6f9e;
body {
font-family: $font;
color: $primary-color;
}
另外,在src/index.html中添加一些 Dom 元素:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Webpack tutorial</title>
</head>
<body>
<h1>Hello webpack!</h1>
<p>Hello sass!</p>
</body>
</html>
最后,將 sass 文件加載到src/index.js中:
import "./style.scss";
console.log("Hello webpack!");
在測試之前,我們需要安裝幾個 loader:
sass-loader:加載 SASS / SCSS 文件并將其編譯為 CSS
css-loader: 解析 css 代碼中的 url、@import語法像import和require一樣去處理css里面引入的模塊
style-loader:幫我們直接將css-loader解析后的內容掛載到html頁面當中
安裝 loader:
npm i css-loader style-loader sass-loader sass --save-dev
然后在webpack.config.js中配置它們:
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /\.scss$/,
use: ["style-loader", "css-loader", "sass-loader"]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src", "index.html")
})
]
};
注意loader的出現順序:首先是sass-loader,然后是css-loader,最后是style-loader。
現在,運行npm start,你應該會在HTML的頭部看到加載的樣式表:
clipboard.png
打包現代 JavaScrip
webpack 本身并不知道如何轉換JavaScript代碼。 該任務已外包給babel的第三方 loader,特別是babel-loader。
babel是一個JavaScript編譯器和“編譯器”。 babel 可以將現代JS(es6, es7...)轉換為可以在(幾乎)任何瀏覽器中運行的兼容代碼。
同樣,要使用它,我們需要安裝一些 Loader:
babel-core :把 js 代碼分析成 ast ,方便各個插件分析語法進行相應的處理
babel-preset-env:將現代 JS 編譯為ES5
babel-loader :用于 webpack
引入依賴關系
npm i @babel/core babel-loader @babel/preset-env --save-dev
接著,創建一個新文件babel.config.json配置babel,內容如下:
{
"presets": [
"@babel/preset-env"
]
}
最后在配置一下 webpack :
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
module: {
rules: [
{
test: /\.scss$/,
use: ["style-loader", "css-loader", "sass-loader"]
},
{
test: /\.js$/,
exclude: /node_modules/,
use: ["babel-loader"]
}
]
},
plugins: [
new HtmlWebpackPlugin({
template: path.resolve(__dirname, "src", "index.html")
})
]
};
要測試轉換,可以在 src/index.js中編寫一些現代語法:
import "./style.scss";
console.log("Hello webpack!");
const fancyFunc = () => {
return [1, 2];
};
const [a, b] = fancyFunc();
現在運行npm run dev來查看dist中轉換后的代碼。 打開 dist/main.js并搜索“fancyFunc”:
\n\nvar fancyFunc = function fancyFunc() {\n return [1, 2];\n};\n\nvar _fancyFunc = fancyFunc(),\n _fancyFunc2 = _slicedToArray(_fancyFunc, 2),\n a = _fancyFunc2[0],\n b = _fancyFunc2[1];\n\n//# sourceURL=webpack:///./src/index.js?"
沒有babel,代碼將不會被轉譯:
\n\nconsole.log(\"Hello webpack!\");\n\nconst fancyFunc = () => {\n return [1, 2];\n};\n\nconst [a, b] = fancyFunc();\n\n\n//# sourceURL=webpack:///./src/index.js?");
注意:即使沒有babel,webpack也可以正常工作。 僅在執行 ES5 代碼時才需要進行代碼轉換過程。
在 Webpack 中使用 JS 的模塊
webpack 將整個文件視為模塊。 但是,請不要忘記它的主要目的:加載ES模塊。
ECMAScript模塊(簡稱ES模塊)是一種JavaScript代碼重用的機制,于2015年推出,一經推出就受到前端開發者的喜愛。在2015之年,JavaScript 還沒有一個代碼重用的標準機制。多年來,人們對這方面的規范進行了很多嘗試,導致現在有多種模塊化的方式。
你可能聽說過AMD模塊,UMD,或CommonJS,這些沒有孰優孰劣。最后,在ECMAScript 2015中,ES 模塊出現了。
我們現在有了一個“正式的”模塊系統。
要在 webpack 使用 ES module ,首先創建 src/common/usersAPI.js 文件:
const ENDPOINT = "https://jsonplaceholder.typicode.com/users/";
export function getUsers() {
return fetch(ENDPOINT)
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => json);
}
在 src/index.js中,引入上面的模塊:
import { getUsers } from "./common/usersAPI";
import "./style.scss";
console.log("Hello webpack!");
getUsers().then(json => console.log(json));
生產方式
如前所述,webpack有兩種操作模式:開發(development )和(production)。 到目前為止,我們僅在開發模式下工作。
在開發模式中,為了便于代碼調試方便我們快速定位錯誤,不會壓縮混淆源代碼。相反,在生產模式下,webpac k進行了許多優化:
使用 TerserWebpackPlugin 進行縮小以減小 bundle 的大小
使用ModuleConcatenationPlugin提升作用域
在生產模式下配 置webpack,請打開 package.json 并添加一個“ build” 命令:
現在運行 npm run build,webpack 會生成一個壓縮的包。
Code splitting
代碼拆分(Code splitting)是指針對以下方面的優化技術:
避免出現一個很大的 bundle
避免重復的依賴關系
webpack 社區考慮到應用程序的初始 bundle 的最大大小有一個限制:200KB。
在 webpack 中有三種激活 code splitting 的主要方法:
有多個入口點
使用 optimization.splitChunks 選項
動態導入
第一種基于多個入口點的技術適用于較小的項目,但是從長遠來看它是不可擴展的。這里我們只關注第二和第三種方式。
Code splitting 與 optimization.splitChunks
考慮一個使用Moment.js 的 JS 應用程序,Moment.js是流行的時間和日期JS庫。
在項目文件夾中安裝該庫:
npm i moment
現在清除src/index.js的內容,并引入 moment 庫:
import moment from "moment";
運行 npm run build 并查看控制的輸出內容:
main.js 350 KiB 0 [emitted] [big] main
整個 moment 庫都綁定到了 main.js 中這樣是不好的。借助optimization.splitChunks,我們可以從主包中移出moment.js。
要使用它,需要在 webpack.config.js 添加 optimization 選項:
const HtmlWebpackPlugin = require("html-webpack-plugin");
const path = require("path");
module.exports = {
module: {
// ...
},
optimization: {
splitChunks: { chunks: "all" }
},
// ...
};
運行npm run build 并查看運行結果:
main.js 5.05 KiB 0 [emitted] main
vendors~main.js 346 KiB 1 [emitted] [big] vendors~main
現在,我們有了一個帶有moment.js 的vendors?main.js,而主入口點的大小更合理。
注意:即使進行代碼拆分,moment.js仍然是一個體積較大的庫。 有更好的選擇,如使用luxon或date-fns。
Code splitting 與 動態導入
Code splitting的一種更強大的技術使用動態導入來有條件地加載代碼。 在ECMAScript 2020中提供此功能之前,webpack 提供了動態導入。
這種方法在 Vue 和 React 之類的現代前端庫中得到了廣泛使用(React有其自己的方式,但是概念是相同的)。
Code splitting 可用于:
模塊級別
路由級別
例如,你可以有條件地加載一些 JavaScript 模塊,以響應用戶的交互(例如單擊或鼠標移動)。 或者,可以在響應路由更改時加載代碼的相關部分。
要使用動態導入,我們先清除src/index.html,并寫入下面的內容:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Dynamic imports</title>
</head>
<body>
<button id="btn">Load!</button>
</body>
</html>
在 src/common/usersAPI.js中:
const ENDPOINT = "https://jsonplaceholder.typicode.com/users/";
export function getUsers() {
return fetch(ENDPOINT)
.then(response => {
if (!response.ok) throw Error(response.statusText);
return response.json();
})
.then(json => json);
}
在 src/index.js 中
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
//
});
如果運行npm run start查看并單擊界面中的按鈕,什么也不會發生。
現在想象一下,我們想在某人單擊按鈕后加載用戶列表。 “原生”的方法可以使用靜態導入從src/common /usersAPI.js加載函數:
import { getUsers } from "./common/usersAPI";
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
getUsers().then(json => console.log(json));
});
問題在于ES模塊是靜態的,這意味著我們無法在運行時更改導入的內容。
通過動態導入,我們可以選擇何時加載代碼
const getUserModule = () => import("./common/usersAPI");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
getUserModule().then(({ getUsers }) => {
getUsers().then(json => console.log(json));
});
});
這里我們創建一個函數來動態加載模塊
const getUserModule = () => import("./common/usersAPI");
現在,當你第一次使用npm run start加載頁面時,會看到控制臺中已加載 js 包:
clipboard.png
現在,僅在單擊按鈕時才加載/common/usersAPI:
clipboard.png
對應的 chunk 是 0.js
通過在導入路徑前面加上魔法注釋/ * webpackChunkName:“ name_here” * /,可以更改塊名稱:
const getUserModule = () =>
import(/* webpackChunkName: "usersAPI" */ "./common/usersAPI");
const btn = document.getElementById("btn");
btn.addEventListener("click", () => {
getUserModule().then(({ getUsers }) => {
getUsers().then(json => console.log(json));
});
});
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
準備工作
//借助插件
npm install babel-plugin-import --save-dev
// .babelrc
{
"plugins": [["import", {
"libraryName": "view-design",
"libraryDirectory": "src/components"
}]]
}
在main.js中引入
import "view-design/dist/styles/iview.css";
import { Button, Table } from "view-design";
const viewDesign = {
Button: Button,
Table: Table
};
Object.keys(viewDesign).forEach(element => {
Vue.component(element, viewDesign[element]);
});
先用google瀏覽器打開正常,以上操作猛如虎,IE瀏覽器打開250,好了不廢話,下面是解決方案
解決方案
//vue.config.js中配置
chainWebpack: config => {
//解決iview 按需引入babel轉換問題
config.module
.rule("view-design") // 我目前用的是新版本的iview ,舊版本的iview,用iview代替view-design
.test(/view-design.src.*?js$/)
.use("babel")
.loader("babel-loader")
.end();
}
問題原因
為什么會有如上問題呢? 這個就和babel轉換問題有關了,按需引入時,那些組件里js文件未進行babel轉換或轉換不徹底就被引入了,ie11對es6+的語法支持是很差的,所以以上方法就是讓引入文件前就對view-design的src下的所有js文件進行babel轉換,舉一反三,當按需引入第三方框架時出現這個問題,都可用這方法解決了,只要把規則和正則中view-design進行替換。
延伸擴展
//全局引入
import ViewUI from "view-design";
Vue.use(ViewUI);
import "view-design/dist/styles/iview.css";
tips:在全局引入時,一定要記住不要在.babelrc文件里配置按需導入,會導致沖突
藍藍設計( www.syprn.cn )是一家專注而深入的界面設計公司,為期望卓越的國內外企業提供卓越的UI界面設計、BS界面設計 、 cs界面設計 、 ipad界面設計 、 包裝設計 、 圖標定制 、 用戶體驗 、交互設計、 網站建設 、平面設計服務
藍藍設計的小編 http://www.syprn.cn