什么是圖片懶加載
當我們向下滾動的時候圖片資源才被請求到,這也就是我們本次要實現的效果,進入頁面的時候,只請求可視區域的圖片資源這也就是懶加載。
比如我們加載一個頁面,這個頁面很長很長,長到我們的瀏覽器可視區域裝不下,那么懶加載就是優先加載可視區域的內容,其他部分等進入了可視區域在加載。
這個功能非常常見,你打開淘寶的首頁,向下滾動,就會看到會有圖片不斷的加載;你在百度中搜索圖片,結果肯定成千上萬條,不可能所有的都一下子加載出來的,很重要的原因就是會有性能問題。你可以在Network中查看,在頁面滾動的時候,會看到圖片一張張加載出來。
lazyLoad
為什么要做圖片懶加載
懶加載是一種網頁性能優化的方式,它能極大的提升用戶體驗。就比如說圖片,圖片一直是影響網頁性能的主要元兇,現在一張圖片超過幾兆已經是很經常的事了。如果每次進入頁面就請求所有的圖片資源,那么可能等圖片加載出來用戶也早就走了。所以,我們需要懶加載,進入頁面的時候,只請求可視區域的圖片資源。
總結出來就兩個點:
1.全部加載的話會影響用戶體驗
2.浪費用戶的流量,有些用戶并不像全部看完,全部加載會耗費大量流量。
懶加載原理
圖片的標簽是 img標簽,圖片的來源主要是 src屬性,瀏覽器是否發起加載圖片的請求是根據是否有src屬性決定的。
所以可以從 img標簽的 src屬性入手,在沒進到可視區域的時候,就先不給 img 標簽的 src屬性賦值。
懶加載實現
實現效果圖:
imgLazyLoad
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<style>
div {
display: flex;
flex-direction: column;
}
img {
width: 100%;
height: 300px;
}
</style>
</head>
<body>
<div>
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657907683.jpeg">
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657913523.jpeg">
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657925550.jpeg">
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657930289.jpeg">
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657934750.jpeg">
<img data-src="https://cdn.suisuijiang.com/ImageMessage/5adad39555703565e79040fa_1590657918315.jpeg">
</div>
</body>
</html>
監聽 scroll 事件判斷元素是否進入視口
const imgs = [...document.getElementsByTagName('img')];
let n = 0;
lazyload();
function throttle(fn, wait) {
let timer = null;
return function(...args) {
if(!timer) {
timer = setTimeout(() => {
timer = null;
fn.apply(this, args)
}, wait)
}
}
}
function lazyload() {
var innerHeight = window.innerHeight;
var scrollTop = document.documentElement.scrollTop || document.body.scrollTop;
for(let i = n; i < imgs.length; i++) {
if(imgs[i].offsetTop < innerHeight + scrollTop) {
imgs[i].src = imgs[i].getAttribute("data-src");
n = i + 1;
}
}
}
window.addEventListener('scroll', throttle(lazyload, 200));
可能會存在下面幾個問題:
每次滑動都要執行一次循環,如果有1000多個圖片,性能會很差
每次讀取 scrollTop 都會引起回流
scrollTop跟DOM的嵌套關系有關,應該根據getboundingclientrect獲取
滑到最后的時候刷新,會看到所有的圖片都加載了
IntersectionObserver
Intersection Observer API提供了一種異步觀察目標元素與祖先元素或文檔viewport的交集中的變化的方法。
創建一個 IntersectionObserver對象,并傳入相應參數和回調用函數,該回調函數將會在目標(target)元素和根(root)元素的交集大小超過閾值(threshold)規定的大小時候被執行。
var observer = new IntersectionObserver(callback, options);
IntersectionObserver是瀏覽器原生提供的構造函數,接受兩個參數:callback是可見性變化時的回調函數(即目標元素出現在root選項指定的元素中可見時,回調函數將會被執行),option是配置對象(該參數可選)。
返回的 observer是一個觀察器實例。實例的 observe 方法可以指定觀察哪個DOM節點。
具體的用法可以 查看 MDN文檔
const imgs = [...document.getElementsByTagName('img')];
// 當監聽的元素進入可視范圍內的會觸發回調
if(IntersectionObserver) {
// 創建一個 intersection observer
let lazyImageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach((entry, index) => {
let lazyImage = entry.target;
// 相交率,默認是相對于瀏覽器視窗
if(entry.intersectionRatio > 0) {
lazyImage.src = lazyImage.getAttribute('data-src');
// 當前圖片加載完之后需要去掉監聽
lazyImageObserver.unobserve(lazyImage);
}
})
})
for(let i = 0; i < imgs.length; i++) {
lazyImageObserver.observe(imgs[i]);
}
}
源碼地址-codePen點擊預覽
vue自定義指令-懶加載
Vue自定義指令
下面的api來自官網自定義指令:
鉤子函數
bind: 只調用一次,指令第一次綁定到元素時調用。在這里可以進行一次性的初始化設置。
inserted: 被綁定元素插入父節點時調用 (僅保證父節點存在,但不一定已被插入文檔中)。
update: 所在組件的 VNode 更新時調用,但是可能發生在其子 VNode 更新之前。指令的值可能發生了改變,也可能沒有。但是你可以通過比較更新前后的值來忽略不必要的模板更新
componentUpdated: 指令所在組件的 VNode 及其子 VNode 全部更新后調用。
unbind: 只調用一次,指令與元素解綁時調用。
鉤子函數參數
指令鉤子函數會被傳入以下參數:
el:指令所綁定的元素,可以用來直接操作 DOM。
binding:一個對象,包含以下 property:
name:指令名,不包括 v- 前綴。
value:指令的綁定值,例如:v-my-directive="1 + 1" 中,綁定值為 2。
oldValue:指令綁定的前一個值,僅在 update 和 componentUpdated 鉤子中可用。無論值是否改變都可用。
expression:字符串形式的指令表達式。例如 v-my-directive="1 + 1" 中,表達式為 "1 + 1"。
arg:傳給指令的參數,可選。例如 v-my-directive:foo 中,參數為 "foo"。
modifiers:一個包含修飾符的對象。例如:v-my-directive.foo.bar 中,修飾符對象為 { foo: true, bar: true }。
vnode:Vue 編譯生成的虛擬節點。
oldVnode:上一個虛擬節點,僅在 update 和 componentUpdated 鉤子中可用。
實現 v-lazyload 指令
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<style>
img {
width: 100%;
height: 300px;
}
</style>
</head>
<body>
<div id="app">
<p v-for="item in imgs" :key="item">
<img v-lazyload="item">
</p>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/vue"></script>
<script>
Vue.directive("lazyload", {
// 指令的定義
bind: function(el, binding) {
火車車次
/^[GCDZTSPKXLY1-9]\d{1,4}$/
手機機身碼(IMEI)
/^\d{15,17}$/
必須帶端口號的網址(或ip)
/^((ht|f)tps?:\/\/)?[\w-]+(\.[\w-]+)+:\d{1,5}\/?$/
網址(url,支持端口和"?+參數"和"#+參數)
/^(((ht|f)tps?):\/\/)?[\w-]+(\.[\w-]+)+([\w.,@?^=%&:\/~+#-]*[\w@?^=%&\/~+#-])?$/
統一社會信用代碼
/^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/
迅雷鏈接
/^thunderx?:\/\/[a-zA-Z\d]+=$/
ed2k鏈接(寬松匹配)
/^ed2k:\/\/\|file\|.+\|\/$/
磁力鏈接(寬松匹配)
/^magnet:\?xt=urn:btih:[0-9a-fA-F]{40,}.*$/
子網掩碼
/^(?:\d{1,2}|1\d\d|2[0-4]\d|25[0-5])(?:\.(?:\d{1,2}|1\d\d|2[0-4]\d|25[0-5])){3}$/
linux"隱藏文件"路徑
/^\/(?:[^\/]+\/)*\.[^\/]*/
linux文件夾路徑
/^\/(?:[^\/]+\/)*$/
linux文件路徑
/^\/(?:[^\/]+\/)*[^\/]+$/
window"文件夾"路徑
/^[a-zA-Z]:\\(?:\w+\\?)*$/
window下"文件"路徑
/^[a-zA-Z]:\\(?:\w+\\)*\w+\.\w+$/
股票代碼(A股)
/^(s[hz]|S[HZ])(000[\d]{3}|002[\d]{3}|300[\d]{3}|600[\d]{3}|60[\d]{4})$/
大于等于0, 小于等于150, 支持小數位出現5, 如145.5, 用于判斷考卷分數
/^150$|^(?:\d|[1-9]\d|1[0-4]\d)(?:.5)?$/
html注釋
/^<!--[\s\S]*?-->$/
md5格式(32位)
/^([a-f\d]{32}|[A-F\d]{32})$/
版本號(version)格式必須為X.Y.Z
/^\d+(?:\.\d+){2}$/
視頻(video)鏈接地址(視頻格式可按需增刪)
/^https?:\/\/(.+\/)+.+(\.(swf|avi|flv|mpg|rm|mov|wav|asf|3gp|mkv|rmvb|mp4))$/i
圖片(image)鏈接地址(圖片格式可按需增刪)
/^https?:\/\/(.+\/)+.+(\.(gif|png|jpg|jpeg|webp|svg|psd|bmp|tif))$/i
24小時制時間(HH:mm:ss)
/^(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d$/
12小時制時間(hh:mm:ss)
/^(?:1[0-2]|0?[1-9]):[0-5]\d:[0-5]\d$/
base64格式
/^\s*data:(?:[a-z]+\/[a-z0-9-+.]+(?:;[a-z-]+=[a-z0-9-]+)?)?(?:;base64)?,([a-z0-9!$&',()*+;=\-._~:@\/?%\s]*?)\s*$/i
數字/貨幣金額(支持負數、千分位分隔符)
/^-?\d+(,\d{3})*(\.\d{1,2})?$/
數字/貨幣金額 (只支持正數、不支持校驗千分位分隔符)
/(?:^[1-9]([0-9]+)?(?:\.[0-9]{1,2})?$)|(?:^(?:0){1}$)|(?:^[0-9]\.[0-9](?:[0-9])?$)/
銀行卡號(10到30位, 覆蓋對公/私賬戶, 參考微信支付)
/^[1-9]\d{9,29}$/
中文姓名
/^(?:[\u4e00-\u9fa5·]{2,16})$/
英文姓名
/(^[a-zA-Z]{1}[a-zA-Z\s]{0,20}[a-zA-Z]{1}$)/
車牌號(新能源)
/[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊使領 A-Z]{1}[A-HJ-NP-Z]{1}(([0-9]{5}[DF])|([DF][A-HJ-NP-Z0-9][0-9]{4}))$/
車牌號(非新能源)
/^[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊使領 A-Z]{1}[A-HJ-NP-Z]{1}[A-Z0-9]{4}[A-Z0-9掛學警港澳]{1}$/
車牌號(新能源+非新能源)
/^(?:[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊使領 A-Z]{1}[A-HJ-NP-Z]{1}(?:(?:[0-9]{5}[DF])|(?:[DF](?:[A-HJ-NP-Z0-9])[0-9]{4})))|(?:[京津滬渝冀豫云遼黑湘皖魯新蘇浙贛鄂桂甘晉蒙陜吉閩貴粵青藏川寧瓊使領 A-Z]{1}[A-Z]{1}[A-HJ-NP-Z0-9]{4}[A-HJ-NP-Z0-9 掛學警港澳]{1})$/
手機號(mobile phone)中國(嚴謹), 根據工信部2019年公布的手機號段
/^(?:(?:\+|00)86)?1(?:(?:3[\d])|(?:4[5-7|9])|(?:5[0-3|5-9])|(?:6[5-7])|(?:7[0-8])|(?:8[\d])|(?:9[1|8|9]))\d{8}$/
手機號(mobile phone)中國(寬松), 只要是13,14,15,16,17,18,19開頭即可
/^(?:(?:\+|00)86)?1[3-9]\d{9}$/
手機號(mobile phone)中國(最寬松), 只要是1開頭即可, 如果你的手機號是用來接收短信, 優先建議選擇這一條
/^(?:(?:\+|00)86)?1\d{10}$/
date(日期)
/^\d{4}(-)(1[0-2]|0?\d)\1([0-2]\d|\d|30|31)$/
email(郵箱)
/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
座機(tel phone)電話(國內),如: 0341-86091234
/^\d{3}-\d{8}$|^\d{4}-\d{7}$/
身份證號(1代,15位數字)
/^[1-9]\d{7}(?:0\d|10|11|12)(?:0[1-9]|[1-2][\d]|30|31)\d{3}$/
身份證號(2代,18位數字),最后一位是校驗位,可能為數字或字符X
/^[1-9]\d{5}(?:18|19|20)\d{2}(?:0\d|10|11|12)(?:0[1-9]|[1-2]\d|30|31)\d{3}[\dXx]$/
身份證號, 支持1/2代(15位/18位數字)
/(^\d{8}(0\d|10|11|12)([0-2]\d|30|31)\d{3}$)|(^\d{6}(18|19|20)\d{2}(0\d|10|11|12)([0-2]\d|30|31)\d{3}(\d|X|x)$)/
護照(包含香港、澳門)
/(^[EeKkGgDdSsPpHh]\d{8}$)|(^(([Ee][a-fA-F])|([DdSsPp][Ee])|([Kk][Jj])|([Mm][Aa])|(1[45]))\d{7}$)/
帳號是否合法(字母開頭,允許5-16字節,允許字母數字下劃線組合
/^[a-zA-Z]\w{4,15}$/
中文/漢字
/^(?:[\u3400-\u4DB5\u4E00-\u9FEA\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0])+$/
小數
/^\d+\.\d+$/
數字
/^\d{1,}$/
html標簽(寬松匹配)
/<(\w+)[^>]*>(.*?<\/\1>)?/
qq號格式正確
/^[1-9][0-9]{4,10}$/
數字和字母組成
/^[A-Za-z0-9]+$/
英文字母
/^[a-zA-Z]+$/
小寫英文字母組成
/^[a-z]+$/
大寫英文字母
/^[A-Z]+$/
密碼強度校驗,最少6位,包括至少1個大寫字母,1個小寫字母,1個數字,1個特殊字符
/^\S*(?=\S{6,})(?=\S*\d)(?=\S*[A-Z])(?=\S*[a-z])(?=\S*[!@#$%^&*? ])\S*$/
用戶名校驗,4到16位(字母,數字,下劃線,減號)
/^[a-zA-Z0-9_-]{4,16}$/
ip-v4
/^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
ip-v6
/^((([0-9A-Fa-f]{1,4}:){7}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}:[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){5}:([0-9A-Fa-f]{1,4}:)?[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){4}:([0-9A-Fa-f]{1,4}:){0,2}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){3}:([0-9A-Fa-f]{1,4}:){0,3}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){2}:([0-9A-Fa-f]{1,4}:){0,4}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){6}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(([0-9A-Fa-f]{1,4}:){0,5}:((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|(::([0-9A-Fa-f]{1,4}:){0,5}((\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b)\.){3}(\b((25[0-5])|(1\d{2})|(2[0-4]\d)|(\d{1,2}))\b))|([0-9A-Fa-f]{1,4}::([0-9A-Fa-f]{1,4}:){0,5}[0-9A-Fa-f]{1,4})|(::([0-9A-Fa-f]{1,4}:){0,6}[0-9A-Fa-f]{1,4})|(([0-9A-Fa-f]{1,4}:){1,7}:))$/i
16進制顏色
/^#?([a-fA-F0-9]{6}|[a-fA-F0-9]{3})$/
微信號(wx),6至20位,以字母開頭,字母,數字,減號,下劃線
/^[a-zA-Z][-_a-zA-Z0-9]{5,19}$/
郵政編碼(中國)
/^(0[1-7]|1[0-356]|2[0-7]|3[0-6]|4[0-7]|5[1-7]|6[1-7]|7[0-5]|8[013-6])\d{4}$/
中文和數字
/^((?:[\u3400-\u4DB5\u4E00-\u9FEA\uFA0E\uFA0F\uFA11\uFA13\uFA14\uFA1F\uFA21\uFA23\uFA24\uFA27-\uFA29]|[\uD840-\uD868\uD86A-\uD86C\uD86F-\uD872\uD874-\uD879][\uDC00-\uDFFF]|\uD869[\uDC00-\uDED6\uDF00-\uDFFF]|\uD86D[\uDC00-\uDF34\uDF40-\uDFFF]|\uD86E[\uDC00-\uDC1D\uDC20-\uDFFF]|\uD873[\uDC00-\uDEA1\uDEB0-\uDFFF]|\uD87A[\uDC00-\uDFE0])|(\d))+$/
不能包含字母
/^[^A-Za-z]*$/
java包名
/^([a-zA-Z_][a-zA-Z0-9_]*)+([.][a-zA-Z_][a-zA-Z0-9_]*)+$/
mac地址
/^((([a-f0-9]{2}:){5})|(([a-f0-9]{2}-){5}))[a-f0-9]{2}$/i
使用 vue-router 的導航守衛鉤子函數,某些鉤子函數可以讓開發者根據業務邏輯,控制是否進行下一步,或者進入到指定的路由。
例如,后臺管理頁面,會在進入路由前,進行必要登錄、權限判斷,來決定去往哪個路由,以下是偽代碼:
// 全局導航守衛
router.beforEach((to, from, next) => {
if('no login'){
next('/login')
}else if('admin') {
next('/admin')
}else {
next()
}
})
// 路由配置鉤子函數
{
path: '',
component: component,
beforeEnter: (to, from, next) => {
next()
}
}
// 組件中配置鉤子函數
{
template: '',
beforeRouteEnter(to, from, next) {
next()
}
}
調用 next,意味著繼續進行下面的流程;不調用,則直接終止,導致路由中設置的組件無法渲染,會出現頁面一片空白的現象。
鉤子函數有不同的作用,例如 beforEach,afterEach,beforeEnter,beforeRouteEnter,beforeRouteUpdate,beforeRouteLeave,針對這些注冊的鉤子函數,要依次進行執行,并且在必要環節有控制權決定是否繼續進入到下一個鉤子函數中。
以下分析下源碼中實現的方式,而源碼中處理的邊界情況比較多,需要抓住核心點,去掉冗余代碼,精簡出便于理解的實現。
精簡源碼核心功能
總結下核心點:鉤子函數注冊的回調函數,能順序執行,同時會將控制權交給開發者。
先來一個能夠注冊回調函數的類:
class VueRouter {
constructor(){
this.beforeHooks = []
this.beforeEnterHooks = []
this.afterHooks = []
}
beforEach(callback){
return registerHook(this.beforeHooks, callback)
}
beforeEnter(callback){
return registerHook(this.beforeEnterHooks, callback)
}
afterEach(callback){
return registerHook(this.afterHooks, callback)
}
}
function registerHook (list, fn) {
list.push(fn)
return () => {
const i = list.indexOf(fn)
if (i > -1) list.splice(i, 1)
}
}
聲明的類,提供了 beforEach 、beforeEnter 和 afterEach 來注冊必要的回調函數。
抽象出一個 registerHook 公共方法,作用:
注冊回調函數
返回的函數,可以取消注冊的回調函數
使用一下:
const router = new VueRouter()
const beforEach = router.beforEach((to, from, next) => {
console.log('beforEach');
next()
})
// 取消注冊的函數
beforEach()
以上的回調函數會被取消,意味著不會執行了。
router.beforEach((to, from, next) => {
console.log('beforEach');
next()
})
router.beforeEnter((to, from, next) => {
console.log('beforeEnter');
next()
})
router.afterEach(() => {
console.log('afterEach');
})
以上注冊的鉤子函數會依次執行。beforEach 和 beforeEnter 的回調接收內部傳來的參數,同時通過調用 next 可繼續走下面的回調函數,如果不調用,則直接被終止了。
最后一個 afterEach 在上面的回調函數都執行后,才被執行,且不接收任何參數。
先來實現依次執行,這是最簡單的方式,在類中增加 run 方法,手動調用:
class VueRouter {
// ... 其他省略,增加 run 函數
run(){
// 把需要依次執行的回調存放在一個隊列中
let queue = [].concat(
this.beforeHooks,
this.afterHooks
)
for(let i = 0; i < queue.length; i++){
if(queue(i)) {
queue(i)('to', 'from', () => {})
}
}
}
}
// 手動調用
router.run()
打?。?
'beforEach'
'beforeEnter'
上面把要依次執行的回調函數聚合在一個隊列中執行,并傳入必要的參數,但這樣開發者不能控制是否進行下一步,即便不執行 next 函數,依然會依次執行完隊列的函數。
改進一下:
class VueRouter {
// ... 其他省略,增加 run 函數
run(){
// 把需要依次執行的回調存放在一個隊列中
let queue = [].concat(
this.beforeHooks,
this.afterHooks
)
queue[0]('to', 'from', () => {
queue[1]('to', 'from', () => {
console.log('調用結束');
})
})
}
}
router.beforEach((to, from, next) => {
console.log('beforEach');
// next()
})
router.beforeEnter((to, from, next) => {
console.log('beforeEnter');
next()
})
傳入的 next 函數會有調用下一個回調函數的行為,把控制權交給了開發者,調用了 next 函數會繼續執行下一個回調函數;不調用 next 函數,則終止了隊列的執行,所以打印結果是:
'beforEach'
上面實現有個弊端,代碼不夠靈活,手動一個個調用,在真實場景中無法確定注冊了多少個回調函數,所以需要繼續抽象成一個功能更強的方法:
function runQueue (queue, fn, cb) {
const step = index => {
// 隊列執行結束了
if (index >= queue.length) {
cb()
} else {
// 隊列有值
if (queue[index]) {
// 傳入隊列中回調,做一些必要的操作,第二個參數是為了進行下一個回調函數
fn(queue[index], () => {
step(index + 1)
})
} else {
step(index + 1)
}
}
}
// 初次調用,從第一個開始
step(0)
}
runQueue 就是執行隊列的通用方法。
第一個參數為回調函數隊列, 會依次取出來;
第二個參數是函數,它接受隊列中的函數,進行一些其他處理;并能進行下個回調函數的執行;
第三個參數是隊列執行結束后調用。
知道了這個函數的含義,來使用一下:
class VueRouter {
// ... 其他省略,增加 run 函數
run(){
// 把需要依次執行的回調存放在一個隊列中
let queue = [].concat(
this.beforeHooks,
this.beforeEnterHooks
)
// 接收回到函數,和進行下一個的執行函數
const iterator = (hook, next) => {
// 傳給回調函數的參數,第三個參數是函數,交給開發者調用,調用后進行下一個
hook('to', 'from', () => {
console.log('執行下一個回調時,處理一些相關信息');
next()
})
}
runQueue(queue, iterator, () => {
console.log('執行結束');
// 執行 afterEach 中的回調函數
this.afterHooks.forEach((fn) => {
fn()
})
})
}
}
// 注冊
router.beforEach((to, from, next) => {
console.log('beforEach');
next()
})
router.beforeEnter((to, from, next) => {
console.log('beforeEnter');
next()
})
router.afterEach(() => {
console.log('afterEach');
})
router.run();
從上面代碼可以看出來,每次把隊列 queue 中的回調函數傳給 iterator , 用 hook 接收,并調用。
傳給 hook 必要的參數,尤其是第三個參數,開發者在注冊的回調函數中調用,來控制進行下一步。
在隊列執行完畢后,依次執行 afterHooks 的回調函數,不傳入任何參數。
所以打印結果為:
beforEach
執行下一個回調時,處理一些相關信息
beforeEnter
執行下一個回調時,處理一些相關信息
執行結束
afterEach
以上實現的非常巧妙,再看 Vue-router 源碼這塊的實現方式,相信你會豁然開朗。
1. 訪問內部屬性
JavaScript 對象無法以常規方式訪問的內部屬性。內部屬性名由雙方括號[[]]包圍,在創建對象時可用。
內部屬性不能動態地添加到現有對象。
內部屬性可以在某些內置 JavaScript 對象中使用,它們存儲ECMAScript規范指定的內部狀態。
有兩種內部屬性,一種操作對象的方法,另一種是存儲數據的方法。例如:
[[Prototype]] — 對象的原型,可以為null或對象
[[Extensible]] — 表示是否允許在對象中動態添加新的屬性
[[PrivateFieldValues]] — 用于管理私有類字段
2. 屬性描述符對象
數據屬性包含了一個數據值的位置,在這個位置可以讀取和寫入值。也就是說,數據屬性可以通過 對象.屬性 訪問,就是我么平常接觸的用戶賦什么值,它們就返回什么,不會做額外的事情。
數據屬性有4個描述其行為的特性(為了表示內部值,把屬性放在兩對方括號中),稱為描述符對象。
屬性 解釋 默認值
[[Configurable]] 能否通過delete刪除屬性從而重新定義屬性;
能否修改屬性的特性;
能否把屬性修改為訪問器屬性 true
[[Enumerable]] 能否通過for-in循環返回屬性 true
[[Writable]] 能否修改屬性的值 true
[[Value]] 包含這個屬性的數據值 undefined
value 描述符是屬性的數據值,例如,我們有以下對象 :
let foo = {
a: 1
}
那么,a 的value屬性描述符為1。
writable是指該屬性的值是否可以更改。 默認值為true,表示屬性是可寫的。 但是,我們可以通過多種方式將其設置為不可寫。
configurable 的意思是可以刪除對象的屬性還是可以更改其屬性描述符。 默認值為true,這意味著它是可配置的。
enumerable 意味著它可以被for ... in循環遍歷。 默認值為true,說明能通過for-in循環返回屬性
將屬性鍵添加到返回的數組之前,Object.keys方法還檢查enumerable 描述符。 但是,Reflect.ownKeys方法不會檢查此屬性描述符,而是返回所有自己的屬性鍵。
Prototype描述符有其他方法,get和set分別用于獲取和設置值。
在創建新對象, 我們可以使用Object.defineProperty方法設置的描述符,如下所示:
let foo = {
a: 1
}
Object.defineProperty(foo, 'b', {
value: 2,
writable: true,
enumerable: true,
configurable: true,
});
這樣得到foo的新值是{a: 1, b: 2}。
我們還可以使用defineProperty更改現有屬性的描述符。 例如:
let foo = {
a: 1
}
Object.defineProperty(foo, 'a', {
value: 2,
writable: false,
enumerable: true,
configurable: true,
});
這樣當我們嘗試給 foo.a 賦值時,如:
foo.a = 2;
如果關閉了嚴格模式,瀏覽器將忽略,否則將拋出一個錯誤,因為我們將 writable 設置為 false, 表示該屬性不可寫。
我們還可以使用defineProperty將屬性轉換為getter,如下所示:
'use strict'
let foo = {
a: 1
}
Object.defineProperty(foo, 'b', {
get() {
return 1;
}
})
當我們這樣寫的時候:
foo.b = 2;
因為b屬性是getter屬性,所以當使用嚴格模式時,我們會得到一個錯誤:Getter 屬性不能重新賦值。
3.無法分配繼承的只讀屬性
繼承的只讀屬性不能再賦值。這是有道理的,因為我們這樣設置它,它是繼承的,所以它應該傳播到繼承屬性的對象。
我們可以使用Object.create創建一個從原型對象繼承屬性的對象,如下所示:
const proto = Object.defineProperties({}, {
a: {
value: 1,
writable: false
}
})
const foo = Object.create(proto)
在上面的代碼中,我們將proto.a的 writable 描述符設置為false,因此我們無法為其分配其他值。
如果我們這樣寫:
foo.a = 2;
在嚴格模式下,我們會收到錯誤消息。
總結
我們可以用 JavaScript 對象做很多我們可能不知道的事情。
首先,某些 JavaScript 對象(例如內置瀏覽器對象)具有內部屬性,這些屬性由雙方括號包圍,它們具有內部狀態,對象創建無法動態添加。
JavaScript對象屬性還具有屬性描述符,該屬性描述符使我們可以控制其值以及可以設置它們的值,還是可以更改其屬性描述符等。
我們可以使用defineProperty更改屬性的屬性描述符,它還用于添加新屬性及其屬性描述符。
最后,繼承的只讀屬性保持只讀狀態,這是有道理的,因為它是從父原型對象繼承而來的。
前言
文章首次發表在 個人博客
之前寫過一篇 web安全之XSS實例解析,是通過舉的幾個簡單例子講解的,同樣通過簡單得例子來理解和學習CSRF,有小伙伴問實際開發中有沒有遇到過XSS和CSRF,答案是有遇到過,不過被測試同學發現了,還有安全掃描發現了可能的問題,這兩篇文章就是簡化了一下當時實際遇到的問題。
CSRF
跨站請求偽造(Cross Site Request Forgery),是指黑客誘導用戶打開黑客的網站,在黑客的網站中,利用用戶的登陸狀態發起的跨站請求。CSRF攻擊就是利用了用戶的登陸狀態,并通過第三方的站點來做一個壞事。
要完成一次CSRF攻擊,受害者依次完成兩個步驟:
登錄受信任網站A,并在本地生成Cookie
在不登出A的情況,訪問危險網站B
CSRF攻擊
在a.com登陸后種下cookie, 然后有個支付的頁面,支付頁面有個誘導點擊的按鈕或者圖片,第三方網站域名為 b.com,中的頁面請求 a.com的接口,b.com 其實拿不到cookie,請求 a.com會把Cookie自動帶上(因為Cookie種在 a.com域下)。這就是為什么在服務端要判斷請求的來源,及限制跨域(只允許信任的域名訪問),然后除了這些還有一些方法來防止 CSRF 攻擊,下面會通過幾個簡單的例子來詳細介紹 CSRF 攻擊的表現及如何防御。
下面會通過一個例子來講解 CSRF 攻擊的表現是什么樣子的。
實現的例子:
在前后端同域的情況下,前后端的域名都為 http://127.0.0.1:3200, 第三方網站的域名為 http://127.0.0.1:3100,釣魚網站頁面為 http://127.0.0.1:3100/bad.html。
平時自己寫例子中會用到下面這兩個工具,非常方便好用:
http-server: 是基于node.js的HTTP 服務器,它最大的好處就是:可以使用任意一個目錄成為服務器的目錄,完全拋開后端的沉重工程,直接運行想要的js代碼;
nodemon: nodemon是一種工具,通過在檢測到目錄中的文件更改時自動重新啟動節點應用程序來幫助開發基于node.js的應用程序
前端頁面: client.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>CSRF-demo</title>
<style>
.wrap {
height: 500px;
width: 300px;
border: 1px solid #ccc;
padding: 20px;
margin-bottom: 20px;
}
input {
width: 300px;
}
.payInfo {
display: none;
}
.money {
font-size: 16px;
}
</style>
</head>
<body>
<div class="wrap">
<div class="loginInfo">
<h3>登陸</h3>
<input type="text" placeholder="用戶名" class="userName">
<br>
<input type="password" placeholder="密碼" class="password">
<br>
<br>
<button class="btn">登陸</button>
</div>
<div class="payInfo">
<h3>轉賬信息</h3>
<p >當前賬戶余額為 <span class="money">0</span>元</p>
<!-- <input type="text" placeholder="收款方" class="account"> -->
<button class="pay">支付10元</button>
<br>
<br>
<a target="_blank">
聽說點擊這個鏈接的人都賺大錢了,你還不來看一下么
</a>
</div>
</div>
</body>
<script>
const btn = document.querySelector('.btn');
const loginInfo = document.querySelector('.loginInfo');
const payInfo = document.querySelector('.payInfo');
const money = document.querySelector('.money');
let currentName = '';
// 第一次進入判斷是否已經登陸
Fetch('http://127.0.0.1:3200/isLogin', 'POST', {})
.then((res) => {
if(res.data) {
payInfo.style.display = "block"
loginInfo.style.display = 'none';
Fetch('http://127.0.0.1:3200/pay', 'POST', {userName: currentName, money: 0})
.then((res) => {
money.innerHTML = res.data.money;
})
} else {
payInfo.style.display = "none"
loginInfo.style.display = 'block';
}
})
// 點擊登陸
btn.onclick = function () {
var userName = document.querySelector('.userName').value;
currentName = userName;
var password = document.querySelector('.password').value;
Fetch('http://127.0.0.1:3200/login', 'POST', {userName, password})
.then((res) => {
payInfo.style.display = "block";
loginInfo.style.display = 'none';
money.innerHTML = res.data.money;
})
}
// 點擊支付10元
const pay = document.querySelector('.pay');
pay.onclick = function () {
Fetch('http://127.0.0.1:3200/pay', 'POST', {userName: currentName, money: 10})
.then((res) => {
console.log(res);
money.innerHTML = res.data.money;
})
}
// 封裝的請求方法
function Fetch(url, method = 'POST', data) {
return new Promise((resolve, reject) => {
let options = {};
if (method !== 'GET') {
options = {
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
}
}
fetch(url, {
mode: 'cors', // no-cors, cors, *same-origin
method,
...options,
credentials: 'include',
}).then((res) => {
return res.json();
}).then(res => {
resolve(res);
}).catch(err => {
reject(err);
});
})
}
</script>
</html>
實現一個簡單的支付功能:
會首先判斷有沒有登錄,如果已經登陸過,就直接展示轉賬信息,未登錄,展示登陸信息
登陸完成之后,會展示轉賬信息,點擊支付,可以實現金額的扣減
后端服務: server.js
const Koa = require("koa");
const app = new Koa();
const route = require('koa-route');
const bodyParser = require('koa-bodyparser');
const cors = require('@koa/cors');
const KoaStatic = require('koa-static');
let currentUserName = '';
// 使用 koa-static 使得前后端都在同一個服務下
app.use(KoaStatic(__dirname));
app.use(bodyParser()); // 處理post請求的參數
// 初始金額為 1000
let money = 1000;
// 調用登陸的接口
const login = ctx => {
const req = ctx.request.body;
const userName = req.userName;
currentUserName = userName;
// 簡單設置一個cookie
ctx.cookies.set(
'name',
userName,
{
domain: '127.0.0.1', // 寫cookie所在的域名
path: '/', // 寫cookie所在的路徑
maxAge: 10 * 60 * 1000, // cookie有效時長
expires: new Date('2021-02-15'), // cookie失效時間
overwrite: false, // 是否允許重寫
SameSite: 'None',
}
)
ctx.response.body = {
data: {
money,
},
msg: '登陸成功'
};
}
// 調用支付的接口
const pay = ctx => {
if(ctx.method === 'GET') {
money = money - Number(ctx.request.query.money);
} else {
money = money - Number(ctx.request.body.money);
}
ctx.set('Access-Control-Allow-Credentials', 'true');
// 根據有沒有 cookie 來簡單判斷是否登錄
if(ctx.cookies.get('name')){
ctx.response.body = {
data: {
money: money,
},
msg: '支付成功'
};
}else{
ctx.body = '未登錄';
}
}
// 判斷是否登陸
const isLogin = ctx => {
ctx.set('Access-Control-Allow-Credentials', 'true');
if(ctx.cookies.get('name')){
ctx.response.body = {
data: true,
msg: '登陸成功'
};
}else{
ctx.response.body = {
data: false,
msg: '未登錄'
};
}
}
// 處理 options 請求
app.use((ctx, next)=> {
const headers = ctx.request.headers;
if(ctx.method === 'OPTIONS') {
ctx.set('Access-Control-Allow-Origin', headers.origin);
ctx.set('Access-Control-Allow-Headers', 'Content-Type');
ctx.set('Access-Control-Allow-Credentials', 'true');
ctx.status = 204;
} else {
next();
}
})
app.use(cors());
app.use(route.post('/login', login));
app.use(route.post('/pay', pay));
app.use(route.get('/pay', pay));
app.use(route.post('/isLogin', isLogin));
app.listen(3200, () => {
console.log('啟動成功');
});
執行 nodemon server.js,訪問頁面 http://127.0.0.1:3200/client.html
CSRF-demo
登陸完成之后,可以看到Cookie是種到 http://127.0.0.1:3200 這個域下面的。
第三方頁面 bad.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>第三方網站</title>
</head>
<body>
<div>
哈哈,小樣兒,哪有賺大錢的方法,還是踏實努力工作吧!
<!-- form 表單的提交會伴隨著跳轉到action中指定 的url 鏈接,為了阻止這一行為,可以通過設置一個隱藏的iframe 頁面,并將form 的target 屬性指向這個iframe,當前頁面iframe則不會刷新頁面 -->
<form action="http://127.0.0.1:3200/pay" method="POST" class="form" target="targetIfr" style="display: none">
<input type="text" name="userName" value="xiaoming">
<input type="text" name="money" value="100">
</form>
<iframe name="targetIfr" style="display:none"></iframe>
</div>
</body>
<script>
document.querySelector('.form').submit();
</script>
</html>
使用 HTTP-server 起一個 本地端口為 3100的服務,就可以通過 http://127.0.0.1:3100/bad.html 這個鏈接來訪問,CSRF攻擊需要做的就是在正常的頁面上誘導用戶點擊鏈接進入這個頁面
CSRF-DEMO
點擊誘導鏈接,跳轉到第三方的頁面,第三方頁面自動發了一個扣款的請求,所以在回到正常頁面的時候,刷新,發現錢變少了。
我們可以看到在第三方頁面調用 http://127.0.0.1:3200/pay 這個接口的時候,Cookie自動加在了請求頭上,這就是為什么 http://127.0.0.1:3100/bad.html 這個頁面拿不到 Cookie,但是卻能正常請求 http://127.0.0.1:3200/pay 這個接口的原因。
CSRF攻擊大致可以分為三種情況,自動發起Get請求, 自動發起POST請求,引導用戶點擊鏈接。下面會分別對上面例子進行簡單的改造來說明這三種情況
自動發起Get請求
在上面的 bad.html中,我們把代碼改成下面這樣
<!DOCTYPE html>
<html>
<body>
<img src="http://127.0.0.1:3200/payMoney?money=1000">
</body>
</html>
當用戶訪問含有這個img的頁面后,瀏覽器會自動向自動發起 img 的資源請求,如果服務器沒有對該請求做判斷的話,那么會認為這是一個正常的鏈接。
自動發起POST請求
上面例子中演示的就是這種情況。
<body>
<div>
哈哈,小樣兒,哪有賺大錢的方法,還是踏實努力工作吧!
<!-- form 表單的提交會伴隨著跳轉到action中指定 的url 鏈接,為了阻止這一行為,可以通過設置一個隱藏的iframe 頁面,并將form 的target 屬性指向這個iframe,當前頁面iframe則不會刷新頁面 -->
<form action="http://127.0.0.1:3200/pay" method="POST" class="form" target="targetIfr">
<input type="text" name="userName" value="xiaoming">
<input type="text" name="money" value="100">
</form>
<iframe name="targetIfr" style="display:none"></iframe>
</div>
</body>
<script>
document.querySelector('.form').submit();
</script>
上面這段代碼中構建了一個隱藏的表單,表單的內容就是自動發起支付的接口請求。當用戶打開該頁面時,這個表單會被自動執行提交。當表單被提交之后,服務器就會執行轉賬操作。因此使用構建自動提交表單這種方式,就可以自動實現跨站點 POST 數據提交。
引導用戶點擊鏈接
誘惑用戶點擊鏈接跳轉到黑客自己的網站,示例代碼如圖所示
<a >聽說點擊這個鏈接的人都賺大錢了,你還不來看一下么</a>
用戶點擊這個地址就會跳到黑客的網站,黑客的網站可能會自動發送一些請求,比如上面提到的自動發起Get或Post請求。
如何防御CSRF
利用cookie的SameSite
SameSite有3個值: Strict, Lax和None
Strict。瀏覽器會完全禁止第三方cookie。比如a.com的頁面中訪問 b.com 的資源,那么a.com中的cookie不會被發送到 b.com服務器,只有從b.com的站點去請求b.com的資源,才會帶上這些Cookie
Lax。相對寬松一些,在跨站點的情況下,從第三方站點鏈接打開和從第三方站點提交 Get方式的表單這兩種方式都會攜帶Cookie。但如果在第三方站點中使用POST方法或者通過 img、Iframe等標簽加載的URL,這些場景都不會攜帶Cookie。
None。任何情況下都會發送 Cookie數據
我們可以根據實際情況將一些關鍵的Cookie設置 Stirct或者 Lax模式,這樣在跨站點請求的時候,這些關鍵的Cookie就不會被發送到服務器,從而使得CSRF攻擊失敗。
驗證請求的來源點
由于CSRF攻擊大多來自第三方站點,可以在服務器端驗證請求來源的站點,禁止第三方站點的請求。
可以通過HTTP請求頭中的 Referer和Origin屬性。
HTTP請求頭
但是這種 Referer和Origin屬性是可以被偽造的,碰上黑客高手,這種判斷就是不安全的了。
CSRF Token
最開始瀏覽器向服務器發起請求時,服務器生成一個CSRF Token。CSRF Token其實就是服務器生成的字符串,然后將該字符串種植到返回的頁面中(可以通過Cookie)
瀏覽器之后再發起請求的時候,需要帶上頁面中的 CSRF Token(在request中要帶上之前獲取到的Token,比如 x-csrf-token:xxxx), 然后服務器會驗證該Token是否合法。第三方網站發出去的請求是無法獲取到 CSRF Token的值的。
其他知識點補充
1. 第三方cookie
Cookie是種在服務端的域名下的,比如客戶端域名是 a.com,服務端的域名是 b.com, Cookie是種在 b.com域名下的,在 Chrome的 Application下是看到的是 a.com下面的Cookie,是沒有的,之后,在a.com下發送b.com的接口請求會自動帶上Cookie(因為Cookie是種在b.com下的)
2. 簡單請求和復雜請求
復雜請求需要處理option請求。
之前寫過一篇特別詳細的文章 CORS原理及@koa/cors源碼解析,有空可以看一下。
3. Fetch的 credentials 參數
如果沒有配置credential 這個參數,fetch是不會發送Cookie的
credential的參數如下
include:不論是不是跨域的請求,總是發送請求資源域在本地的Cookies、HTTP Basic anthentication等驗證信息
same-origin:只有當URL與響應腳本同源才發送 cookies、 HTTP Basic authentication 等驗證信息
omit: 從不發送cookies.
平常寫一些簡單的例子,從很多細節問題上也能補充自己的一些知識盲點。
做項目的時候準備把js項目重構成ts項目,需要把文件后綴改成ts,一個bat腳本搞定,命令如下:
@echo off
rem 正在搜索...
for /f "delims=" %%i in ('dir /b /a-d /s "*.js"') do ren "%%i" "%%~ni.ts" rem 搜索完畢 @pause
把腳本放到根目錄下,雙擊運行完就可以了
網上可以找到前端開發社區貢獻的大量工具,這篇文章列出了我最喜歡的一些工具,這些工具給我的工作帶來了許多便利。
1. EnjoyCSS
老實說,雖然我做過許多前端開發,但我并不擅長 CSS。當我陷入困境時,EnjoyCSS 是我的大救星。EnjoyCSS 提供了一個簡單的交互界面,幫助我設計元素,然后自動輸出相應的 CSS 代碼。
EnjoyCSS 可以輸出 CSS、LESS、SCSS 代碼,并支持指定需要支持哪些瀏覽器及其版本。開發簡單頁面時用起來比較方便,但不太適合復雜一點的前端項目(這類項目往往需要引入 CSS 框架)。
2. Prettier Playground
Prettier 是一個代碼格式化工具,支持格式化 JavaScript 代碼(包括 ES2017、JSX、Angular、Vue、Flow、TypeScript 等)。Prettier 會移除代碼原本的樣式,替換為遵循最佳實踐的標準化、一致的樣式。IDE 大多支持 Prettier 工具,不過 Prettier 也有在線版本,讓你可以在瀏覽器里格式化代碼。
如果工作電腦不在手邊,使用移動端設備或者臨時借用別人的電腦查看代碼時,Prettier Playground 非常好用。相比在 IDE 或編輯器下使用 Prettier,個人更推薦通過 git pre-commit hook 配置 Prettier:hook 可以保證整個團隊使用統一的配置,免去各自分別配置 IDE 或編輯器的麻煩。如果是老項目,hook 還可以設置只格式化有改動的單個文件甚至有改動的代碼段,避免在 IDE 或編輯器下使用 Prettier 時不小心格式了大量代碼,淹沒了 commit 的主要改動,讓 review 代碼變得十分痛苦。
3. Postman
Postman 一直在我的開發工具箱里,測試后端 API 接口時非常好用。GET、POST、DELETE、OPTIONS、PUT 這些方法都支持。毫無疑問,你應該使用這個工具。
Postman 之外,Insomnia 也是很流行的 REST API 測試工具,亮點是支持 GraphQL。不過 Postman 從 去年夏天發布的 v7.2 起也支持了 GraphQL。
4. StackBlitz
Chidume Nnamdi 盛贊這是每個用戶最喜歡的在線 IDE。StackBlitz 將大家最喜歡、最常用的 IDE Visual Studio Code 搬進了瀏覽器。
StackBlitz 支持一鍵配置 Angular、React、Ionic、TypeScript、RxJS、Svelte 等 JavaScript 框架,也就是說,只需幾秒你就可以開始寫代碼了。
我覺得這個在線 IDE 很有用,特別是可以在線嘗試一些樣例代碼或者庫,否則僅僅嘗試一些新特性就需要花很多時間在新項目初始化配置上。有了 StackBlitz,無需在本地從頭搭建環境,花上幾分鐘就可以試用一個 NPM 包。很棒,不是嗎?
微軟官方其實也提供了在線版本的 VSCode,可以在瀏覽器內使用 VSCode,并且支持開發 Node.js 項目(基于 Azure)。不過 StackBlitz 更專注于優化前端開發體驗,界面更加直觀一點,也推出了 beta 版本的 Node.js 支持(基于 GCP,需要填表申請)。
5. Bit.dev
軟件開發的基本原則之一就是代碼復用。代碼復用減少了開發量,讓你不用從頭開發組件。
這正是 Bit.dev 做的事,分享可重用的組件和片段,降低開發量,加速開發進程。
除了公開分享,它還支持在團隊分享,讓團隊協作更方便。
正如 Bit.dev 的口號「組件即設計體系。協同開發更好的組件?!顾?,Bit.dev 可以用來創建設計體系,允許團隊內的開發者和設計師一起協作,從頭搭建一套設計體系。
Bit.dev 目前支持 React、Vue、Angular、Node 及其他 JavaScript 框架。
在 Bit.dev 上不僅可以搜索組件,還可以直接查看組件的依賴,瀏覽組件的代碼,甚至在線編輯代碼并查看預覽效果!選好組件后可以通過 Bit.dev 的命令行工具 bit 在本地項目引入組件,也可以通過 npm、yarn 引入組件。
6. CanIUse
CanIUse是非常好用的在線工具,可以方便地查看各大瀏覽器對某個特性的支持程度。
我過去經常碰到自己開發的應用的一些功能在其他瀏覽器下不支持的情況。比如我的作品集項目使用的某個特性在 Safari 下不支持,直到項目上線幾個月后我才意識到。這些經驗教訓讓我意識到需要檢查瀏覽器兼容性。
我們來看一個例子吧。哪些瀏覽器支持 WebP 圖像格式?
如你所見,Safari 和 IE 目前不支持 WebP。這意味著需要為不兼容的瀏覽器提供回退選項,比如:
<picture>
CanIUse 還可以在命令行下使用,例如,在命令行下查看 WebP 圖像格式的瀏覽器兼容性:caniuse webp(運行命令前需要事先通過 npm install -g caniuse-cmd安裝命令行工具。
方法參數的驗證
JavaScript 允許你設置參數的默認值。通過這種方法,可以通過一個巧妙的技巧來驗證你的方法參數。
const isRequired = () => { throw new Error('param is required'); };
const print = (num = isRequired()) => { console.log(`printing ${num}`) };
print(2);//printing 2
print()// error
print(null)//printing null
非常整潔,不是嗎?
格式化 json 代碼
你可能對 JSON.stringify 非常熟悉。但是你是否知道可以用 stringify 進行格式化輸出?實際上這很簡單。
stringify 方法需要三個輸入。 value,replacer 和 space。后兩個是可選參數。這就是為什么我們以前沒有注意過它們。要對 json 進行縮進,必須使用 space 參數。
console.log(JSON.stringify({name:"John",Age:23},null,'\t'));
>>>
{
"name": "John",
"Age": 23
}
從數組中獲取唯一值
要從數組中獲取唯一值,我們需要使用 filter 方法來過濾出重復值。但是有了新的 Set 對象,事情就變得非常順利和容易了。
let uniqueArray = [...new Set([1, 2, 3, 3, 3, "school", "school", 'ball', false, false, true, true])];
>>> [1, 2, 3, "school", "ball", false, true]
從數組中刪除虛值(Falsy Value)
在某些情況下,你可能想從數組中刪除虛值。虛值是 JavaScript 的 Boolean 上下文中被認定為為 false 的值。 JavaScript 中只有六個虛值,它們是:
undefined
null
NaN
0
"" (空字符串)
false
濾除這些虛值的最簡單方法是使用以下函數。
myArray.filter(Boolean);
如果要對數組進行一些修改,然后過濾新數組,可以嘗試這樣的操作。請記住,原始的 myArray 會保持不變。
myArray
.map(item => {
// Do your changes and return the new item
})
.filter(Boolean);
合并多個對象
假設我有幾個需要合并的對象,那么這是我的首選方法。
const user = {
name: 'John Ludwig',
gender: 'Male'
};
const college = {
primary: 'Mani Primary School',
secondary: 'Lass Secondary School'
};
const skills = {
programming: 'Extreme',
swimming: 'Average',
sleeping: 'Pro'
};
const summary = {...user, ...college, ...skills};
這三個點在 JavaScript 中也稱為展開運算符。你可以在這里學習更多用法。
對數字數組進行排序
JavaScript 數組有內置的 sort 方法。默認情況下 sort 方法把數組元素轉換為字符串,并對其進行字典排序。在對數字數組進行排序時,這有可能會導致一些問題。所以下面是解決這類問題的簡單解決方案。
[0,10,4,9,123,54,1].sort((a,b) => a-b);
>>> [0, 1, 4, 9, 10, 54, 123]
這里提供了一個將數字數組中的兩個元素與 sort 方法進行比較的函數。這個函數可幫助我們接收正確的輸出。
Disable Right Click
禁用右鍵
你可能想要阻止用戶在你的網頁上單擊鼠標右鍵。
<body oncontextmenu="return false">
<div></div>
</body>
這段簡單的代碼將為你的用戶禁用右鍵單擊。
使用別名進行解構
解構賦值語法是一種 JavaScript 表達式,可以將數組中的值或對象的值或屬性分配給變量。解構賦值能讓我們用更簡短的語法進行多個變量的賦值。
const object = { number: 10 };
// Grabbing number
const { number } = object;
// Grabbing number and renaming it as otherNumber
const { number: otherNumber } = object;
console.log(otherNumber); //10
獲取數組中的最后一項
可以通過對 splice 方法的參數傳入負整數,來數獲取組末尾的元素。
let array = [0, 1, 2, 3, 4, 5, 6, 7]
console.log(array.slice(-1));
>>>[7]
console.log(array.slice(-2));
>>>[6, 7]
console.log(array.slice(-3));
>>>[5, 6, 7]
等待 Promise 完成
在某些情況下,你可能會需要等待多個 promise 結束。可以用 Promise.all 來并行運行我們的 promise。
const PromiseArray = [
Promise.resolve(100),
Promise.reject(null),
Promise.resolve("Data release"),
Promise.reject(new Error('Something went wrong'))];
Promise.all(PromiseArray)
.then(data => console.log('all resolved! here are the resolve values:', data))
.catch(err => console.log('got rejected! reason:', err))
關于 Promise.all 的主要注意事項是,當一個 Promise 拒絕時,該方法將引發錯誤。這意味著你的代碼不會等到你所有的 promise 都完成。
如果你想等到所有 promise 都完成后,無論它們被拒絕還是被解決,都可以使用 Promise.allSettled。此方法在 ES2020 的最終版本得到支持。
const PromiseArray = [
Promise.resolve(100),
Promise.reject(null),
Promise.resolve("Data release"),
Promise.reject(new Error('Something went wrong'))];
Promise.allSettled(PromiseArray).then(res =>{
console.log(res);
}).catch(err => console.log(err));
//[
//{status: "fulfilled", value: 100},
//{status: "rejected", reason: null},
//{status: "fulfilled", value: "Data release"},
//{status: "rejected", reason: Error: Something went wrong ...}
//]
即使某些 promise 被拒絕,Promise.allSettled 也會從你所有的 promise 中返回結果。
背景
這一個因為滾動條占據空間引起的bug, 查了一下資料, 最后也解決了,順便研究一下這個屬性, 做一下總結,分享給大家看看。
正文
昨天, 測試提了個問題, 現象是一個輸入框的聚焦提示偏了, 讓我修一下, 如下圖:
image.png
起初認為是紅框提示位置不對, 就去找代碼看:
<Input
// ...
onFocus={() => setFocusedInputName('guidePrice')}
onBlur={() => setFocusedInputName('')}
/>
<Table
data-focused-column={focusedInputName}
// ...
/>
代碼上沒有什么問題, 不是手動設置的,而且, 在我和另一個同事, 還有PM的PC上都是OK的:
image.png
初步判斷是,紅框位置結算有差異, 差異大小大概是17px, 但是這個差異是怎么產生的呢?
就去測試小哥的PC上看, 注意到一個細節, 在我PC上, 滾動條是懸浮的:
image.png
在他PC上, 滾動條是占空間的:
image.png
在他電腦上, 手動把原本的 overscroll-y: scroll 改成 overscroll-y: overlay 問題就結局了。
由此判定是: 滾動條占據空間 引起的bug。
overscroll-y: overlay
CSS屬性 overflow, 定義當一個元素的內容太大而無法適應塊級格式化上下文的時候該做什么。它是 overflow-x 和overflow-y的 簡寫屬性 。
/* 默認值。內容不會被修剪,會呈現在元素框之外 */
overflow: visible;
/* 內容會被修剪,并且其余內容不可見 */
overflow: hidden;
/* 內容會被修剪,瀏覽器會顯示滾動條以便查看其余內容 */
overflow: scroll;
/* 由瀏覽器定奪,如果內容被修剪,就會顯示滾動條 */
overflow: auto;
/* 規定從父元素繼承overflow屬性的值 */
overflow: inherit;
官方描述:
overlay 行為與 auto 相同,但滾動條繪制在內容之上而不是占用空間。 僅在基于 WebKit(例如,Safari)和基于Blink的(例如,Chrome或Opera)瀏覽器中受支持。
表現:
html {
overflow-y: overlay;
}
兼容性
沒有在caniuse上找到這個屬性的兼容性, 也有人提這個問題:
image.png
問題場景以及解決辦法
1. 外部容器的滾動條
這里的外部容器指的是html, 直接加在最外層:
html {
overflow-y: scroll;
}
手動加上這個特性, 不論什么時候都有滾動寬度占據空間。
缺點: 沒有滾動的時候也會有個滾動條, 不太美觀。
優點: 方便, 沒有兼容性的問題。
2. 外部容器絕對定位法
用絕對定位,保證了body的寬度一直保持完整空間:
html {
overflow-y: scroll; // 兼容ie8,不支持:root, vw
}
:root {
overflow-y: auto;
overflow-x: hidden;
}
:root body {
position: absolute;
}
body {
width: 100vw;
overflow: hidden;
}
3. 內部容器做兼容
.wrapper {
overflow-y: scroll; // fallback
overflow-y: overlay;
}
總結
個人推薦還是用 overlay, 然后使用scroll 做為兜底。
內容就這么多, 希望對大家有所啟發。
文章如有錯誤, 請在留言區指正, 謝謝。
之前花了些時間將gatsby-theme-gitbook遷移到 Typescript,以獲得在 VSCode 中更好的編程體驗.
整體差不多已經完成遷移,剩下將 Gatsby 的 API 文件也遷移到 TS,這里可以看到 gatsby#21995 官方也在將核心代碼庫遷移到 Typescript,準備等待官方將核心代碼庫遷移完成,在遷移 API 文件.
這篇文章用XYShaoKang/gatsby-project-config,演示如何將 gatsby 遷移到 TypeScript,希望能幫到同樣想要在 Gatsby 中使用 TS 的同學.
遷移步驟:
TS 配置
配置 ESLint 支持 TS
完善 GraphQL 類型提示
初始化項目
gatsby new gatsby-migrate-to-typescript XYShaoKang/gatsby-project-config
cd gatsby-migrate-to-typescript
yarn develop
TS 配置
安裝typescript
添加typescript.json配置文件
修改 js 文件為 tsx
補全 TS 聲明定義
安裝typescript
yarn add -D typescript
添加配置文件tsconfig.json
// https://www.typescriptlang.org/v2/docs/handbook/tsconfig-json.html
{
"compilerOptions": {
"target": "esnext", // 編譯生成的目標 es 版本,可以根據需要設置
"module": "esnext", // 編譯生成的目標模塊系統
"lib": ["dom", "es2015", "es2017"], // 配置需要包含的運行環境的類型定義
"jsx": "react", // 配置 .tsx 文件的輸出模式
"strict": true, // 開啟嚴格模式
"esModuleInterop": true, // 兼容 CommonJS 和 ES Module
"moduleResolution": "node", // 配置模塊的解析規則,支持 node 模塊解析規則
"noUnusedLocals": true, // 報告未使用的局部變量的錯誤
"noUnusedParameters": true, // 報告有關函數中未使用參數的錯誤
"experimentalDecorators": true, // 啟用裝飾器
"emitDecoratorMetadata": true, // 支持裝飾器上生成元數據,用來進行反射之類的操作
"noEmit": true, // 不輸出 js,源映射或聲明之類的文件,單純用來檢查錯誤
"skipLibCheck": true // 跳過聲明文件的類型檢查,只會檢查已引用的部分
},
"exclude": ["./node_modules", "./public", "./.cache"], // 解析時,應該跳過的路晉
"include": ["src"] // 定義包含的路徑,定義在其中的聲明文件都會被解析進 vscode 的智能提示
}
將index.js改成index.tsx,重新啟動服務,查看效果.
其實 Gatsby 內置了支持 TS,不用其他配置,只要把index.js改成index.tsx就可以直接運行.添加 TS 依賴是為了顯示管理 TS,而tsconfig.json也是這個目的,當我們有需要新的特性以及自定義配置時,可以手動添加.
補全 TS 聲明定義
打開index.tsx,VSCode 會報兩個錯誤,一個是找不到styled-components的聲明文件,這個可以通過安裝@types/styled-components來解決.
另外一個錯誤綁定元素“data”隱式具有“any”類型。,這個錯誤是因為我們在tsconfig.json中指定了"strict": true,這會開啟嚴格的類型檢查,可以通過關閉這個選項來解決,只是我們用 TS 就是要用它的類型檢查的,所以正確的做法是給data定義類型.
下面來一一修復錯誤.
安裝styled-components的聲明文件
yarn add -D @types/styled-components
修改index.tsx
import React, { FC } from 'react'
import styled from 'styled-components'
import { graphql } from 'gatsby'
import { HomeQuery } from './__generated__/HomeQuery'
const Title = styled.h1`
font-size: 1.5em;
margin: 0;
padding: 0.5em 0;
color: palevioletred;
background: papayawhip;
`
const Content = styled.div`
margin-top: 0.5em;
`
interface PageQuery {
data: {
allMarkdownRemark: {
edges: Array<{
node: {
frontmatter: {
title: string
}
excerpt: string
}
}>
}
}
}
const Home: FC<PageQuery> = ({ data }) => {
const node = data.allMarkdownRemark.edges[0].node
const title = node.frontmatter?.title
const excerpt = node.excerpt
return (
<>
<Title>{title}</Title>
<Content>{excerpt}</Content>
</>
)
}
export default Home
export const query = graphql`
query HomeQuery {
allMarkdownRemark {
edges {
node {
frontmatter {
title
}
excerpt
}
}
}
}
`
這時候會出現一個新的錯誤,在excerpt: string處提示Parsing error: Unexpected token,這是因為 ESLint 還無法識別 TS 的語法,下面來配置 ESLint 支持 TS.
配置 ESLint 支持 TypeScript
安裝依賴
yarn add -D @typescript-eslint/parser @typescript-eslint/eslint-plugin
配置.eslintrc.js
module.exports = {
parser: `@typescript-eslint/parser`, // 將解析器從`babel-eslint`替換成`@typescript-eslint/parser`,用以解析 TS 代碼
extends: [
`google`,
`eslint:recommended`,
`plugin:@typescript-eslint/recommended`, // 使用 @typescript-eslint/eslint-plugin 推薦配置
`plugin:react/recommended`,
`prettier/@typescript-eslint`, // 禁用 @typescript-eslint/eslint-plugin 中與 prettier 沖突的規則
`plugin:prettier/recommended`,
],
plugins: [
`@typescript-eslint`, // 處理 TS 語法規則
`react`,
`filenames`,
],
// ...
}
在.vscode/settings.json中添加配置,讓VSCode使用ESLint擴展格式化ts和tsx文件
// .vscode/settings.json
{
"eslint.format.enable": true,
"[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[javascriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
},
"[typescriptreact]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
}
}
完善 GraphQL 類型提示
// index.tsx
import React, { FC } from 'react'
// ...
interface PageQuery {
data: {
allMarkdownRemark: {
edges: Array<{
node: {
frontmatter: {
title: string
}
excerpt: string
}
}>
}
}
}
const Home: FC<PageQuery> = ({ data }) => {
// ...
}
export default Home
export const query = graphql`
query HomeQuery {
allMarkdownRemark {
edges {
node {
frontmatter {
title
}
excerpt
}
}
}
}
`
我們看看index.tsx文件,會發現PropTypes和query結構非常類似,在Gatsby運行時,會把query查詢的結果作為組件prop.data傳入組件,而PropTypes是用來約束prop存在的.所以其實PropTypes就是根據query寫出來的.
如果有依據query自動生成PropTypes的功能就太棒了.
另外一個問題是在query中編寫GraphQL查詢時,并沒有類型約束,也沒有智能提示.
總結以下需要完善的體驗包括:
GraphQL 查詢編寫時的智能提示,以及錯誤檢查
能夠從 GraphQL 查詢生成對應的 TypeScript 類型.這樣能保證類型的唯一事實來源,并消除 TS 中冗余的類型聲明.畢竟如果經常需要手動更新兩處類型,會更容易出錯,而且也并不能保證手動定義類型的正確性.
實現方式:
通過生成架構文件,配合Apollo GraphQL for VS Code插件,實現智能提示,以及錯誤檢查
通過graphql-code-generator或者apollo生成 TS 類型定義文件
如果自己去配置的話,是挺耗費時間的,需要去了解graphql-code-generator的使用,以及Apollo的架構等知識.
不過好在社區中已經有對應的 Gatsby 插件集成了上述工具可以直接使用,能讓我們不用去深究對應知識的情況下,達到優化 GraphQL 編程的體驗.
嘗試過以下兩個插件能解決上述問題,可以任選其一使用
gatsby-plugin-codegen
gatsby-plugin-typegen
另外還有一款插件gatsby-plugin-graphql-codegen也可以生成 TS 類型,不過配置略麻煩,并且上述兩個插件都可以滿足我現在的需求,所以沒有去嘗試,感興趣的可以嘗試一下.
注意點:
Apollo不支持匿名查詢,需要使用命名查詢
第一次生成,需要運行Gatsby之后才能生成類型文件
整個項目內不能有相同命名的查詢,不然會因為名字有沖突而生成失敗
下面是具體操作
安裝vscode-apollo擴展
在 VSCode 中按 Ctrl + P ( MAC 下: Cmd + P) 輸入以下命令,按回車安裝
ext install apollographql.vscode-apollo
方式一: 使用gatsby-plugin-codegen
gatsby-plugin-codegen默認會生成apollo.config.js和schema.json,配合vscode-apollo擴展,可以提供GraphQL的類型約束和智能提示.
另外會自動根據query中的GraphQL查詢,生成 TS 類型,放在對應的tsx文件同級目錄下的__generated__文件夾,使用時只需要引入即可.
如果需要在運行時自動生成 TS 類型,需要添加watch: true配置.
安裝gatsby-plugin-codegen
yarn add gatsby-plugin-codegen
配置gatsby-config.js
// gatsby-config.js
module.exports = {
plugins: [
// ...
{
resolve: `gatsby-plugin-codegen`,
options: {
watch: true,
},
},
],
}
重新運行開發服務生成類型文件
yarn develop
如果出現以下錯誤,一般是因為沒有為查詢命名的緣故,給查詢添加命名即可,另外配置正確的話,打開對應的文件,有匿名查詢,編輯器會有錯誤提示.
fix-anonymous-operations.png
這個命名之后會作為生成的類型名.
修改index.tsx以使用生成的類型
gatsby-plugin-codegen插件會更具查詢生成對應的查詢名稱的類型,保存在對應tsx文件同級的__generated__目錄下.
import { HomeQuery } from './__generated__/HomeQuery' // 引入自動生成的類型
// ...
// interface PageQuery {
// data: {
// allMarkdownRemark: {
// edges: Array<{
// node: {
// frontmatter: {
// title: string
// }
// excerpt: string
// }
// }>
// }
// }
// }
interface PageQuery {
data: HomeQuery // 替換之前手寫的類型
}
// ...
將自動生成的文件添加到.gitignore中
apollo.config.js,schema.json,__generated__能通過運行時生成,所以可以添加到.gitignore中,不用提交到 git 中.當然如果有需要也可以選擇提交到 git 中.
# Generated types by gatsby-plugin-codegen
__generated__
apollo.config.js
schema.json
方式二: 使用gatsby-plugin-typegen
gatsby-plugin-typegen通過配置生成gatsby-schema.graphql和gatsby-plugin-documents.graphql配合手動創建的apollo.config.js提供GraphQL的類型約束和智能提示.
根據GraphQL查詢生成gatsby-types.d.ts,生成的類型放在命名空間GatsbyTypes下,使用時通過GatsbyTypes.HomeQueryQuery來引入,HomeQueryQuery是由對應的命名查詢生成
安裝gatsby-plugin-typegen
yarn add gatsby-plugin-typegen
配置
// gatsby-config.js
module.exports = {
plugins: [
// ...
{
resolve: `gatsby-plugin-typegen`,
options: {
outputPath: `src/__generated__/gatsby-types.d.ts`,
emitSchema: {
'src/__generated__/gatsby-schema.graphql': true,
},
emitPluginDocuments: {
'src/__generated__/gatsby-plugin-documents.graphql': true,
},
},
},
],
}
//apollo.config.js
module.exports = {
client: {
tagName: `graphql`,
includes: [
`./src/**/*.{ts,tsx}`,
`./src/__generated__/gatsby-plugin-documents.graphql`,
],
service: {
name: `GatsbyJS`,
localSchemaFile: `./src/__generated__/gatsby-schema.graphql`,
},
},
}
重新運行開發服務生成類型文件
yarn develop
修改index.tsx以使用生成的類型
gatsby-plugin-codegen插件會更具查詢生成對應的查詢名稱的類型,保存在對應tsx文件同級的__generated__目錄下.
// ...
// interface PageQuery {
// data: {
// allMarkdownRemark: {
// edges: Array<{
// node: {
// frontmatter: {
// title: string
// }
// excerpt: string
// }
// }>
// }
// }
// }
interface PageQuery {
data: GatsbyTypes.HomeQueryQuery // 替換之前手寫的類型
}
// ...
將自動生成的文件添加到.gitignore中
__generated__能通過運行時生成,所以可以添加到.gitignore中,不用提交到 git 中.當然如果有需要也可以選擇提交到 git 中.
# Generated types by gatsby-plugin-codegen
__generated__
藍藍設計的小編 http://www.syprn.cn