蔣鵬飛

蔣鵬飛 查看完整檔案

成都編輯四川大學  |  計算機科學與技術 編輯  |  填寫所在公司/組織 github.com/dennis-jiang/Front-End-Knowledges 編輯
編輯

前端工程師,底層技術人。
思否2020年度“Top Writer”!
掘金“優秀作者”!
開源中國2020年度“優秀源創作者”!
分享各種大前端進階知識!
關注公眾號【進擊的大前端】第一時間獲取高質量原創。
更多文章和示例源碼請看:https://github.com/dennis-jia...

個人動態

蔣鵬飛 發布了文章 · 2月23日

技術寫作技巧分享:我是如何從寫作小白成長為多平臺優秀作者的?

我從事技術寫作的時間其實不長,開始寫作的時間就是我掘金賬號注冊的時間:

image-20210219170755430

到今天(2021年2月23日)也就是一年零一個月,這一年的收獲是超過我的預期的:

  1. 產出博文四十多篇,總共數十萬字
  2. 掘金優秀作者,掘金年度人氣作者No.27
  3. 思否2020年度"Top Writer",萬粉專欄作者
  4. 開源中國優秀源創作者,源創計劃年度活躍博主 Top20

本文想對這個歷程做一個回顧,并分享一下我總結的寫作技巧以及推廣策略。

為什么寫作

在寫作之前想清楚為什么寫作非常重要!因為你最初的想法會決定你往哪個方向去寫,寫出的內容的質量怎么樣。

我寫作的原因很簡單,就是我前端做了幾年了,大部分時間都在寫業務代碼,技術上一直沒有太大的突破,最多也就是換個框架,換個UI庫,換來換去始終感覺似曾相識。為了不讓幾年工作經驗成為“第一年工作經驗的復制品”,我決定再深入,系統的學習下前端知識。所以對于我來說,寫作是我的學習方法,我的首要目的是學習知識,寫作帶來的社區聲望只是附帶的,有了當然好,沒有也沒必要刻意去刷。

“為學習而寫”與“為刷聲望而寫”

根據我的觀察,社區上的作者寫作目的主要分為兩種:“為學習而寫”與“為刷聲望而寫”。

大部分厲害的大佬其實都是“為學習而寫”,就是他們看到什么好玩的,新奇的技術,去學習了,自然而然的總結出文章?;蛘哂X得某個知識點大家很容易搞錯,想輸出自己的觀點,幫大家避坑,就將自己的見解寫成文章,這個過程作者雖然更多的是在輸出內容,但是寫作的過程其實也會強化作者自己的理解,其實也是一個學習方法。我個人認為“為學習而寫”寫出的文章才是正道,是社區良性發展的方向。

當然也有少部分作者想在短時間內獲取更多關注而刻意的去迎合讀者口味,也就是“為刷聲望而寫”。比較典型的一個例子就是,掘金曾經在某段時期被大量的面試題匯總占據。大家出去面試了回來分享下心得其實是好事,但是刻意的去搜集面試題,相似的內容發了一遍又一遍,里面的答案甚至還是錯的,會導致社區越來越功利,低質量面試題霸版,高質量技術文章反而沒機會展示,從而造成劣幣驅逐良幣的現象。我記得那會兒有個作者靠反復發面試題,短時間就刷了三四千掘力值,眼看就要到“優秀作者”了,結果被一個社區大佬懟了,然后就沒怎么露面了。這樣,前面刷的幾千聲望不是都白費了嗎?后來掘金官方也整治了低質量的面試題文章,現在的情況已經好多了。

所以我說,寫作前想清楚“為學習而寫”與“為刷聲望而寫”很重要,如果是“為學習而寫”,那就可以寫出自己的心得體會,寫出高質量文章,如果單純是“為刷聲望而寫”,可能短期會有點收益,但是也有可能會被大佬懟,被官方整治,前功盡棄。

寫什么

在這個“系統學習計劃”開始之前,我其實沒怎么寫過技術文章,甚至都沒怎么逛過技術社區。平時如果需要學習一個東西,比如學習React,那我會直接去它的官方網站,把它的文檔全部讀一遍,現在這些流行庫的文檔都寫的很好,看一遍基本就能上手了。如果看完了還是不太知道怎么用,那就去公司看看有沒有項目用過,公司沒用過,就去GitHub上找找,然后抄抄改改就能上手了。這個過程一般也就幾天,復雜的庫最多也就一兩周就能上手。使用的時候遇到問題就用Google搜,基本都會找到Stack Overflow上,答案拿過來一用就行。

前面幾年我的工作模式基本都是這樣的,這樣應付工作也沒啥問題,但是第一年是這樣,第二年是這樣,第n年還是這樣。。。就成了“一年工作經驗復用n年”,成了名副其實的“API工程師”,做項目沒問題,問原理似曾相識,但是卻說不太清楚。如果一直這樣,技術就會一直原地踏步,在現單位很容易被替代,出去找工作也可能會四處碰壁,或者找來找去找到的始終跟當前的差不多,很難實現大的突破。

我感到,我碰到瓶頸了。我想突破這個瓶頸,但是我不知道怎么做!在沒有具體方向的時候,就看看手上能做啥吧,從簡單的,可見的開始做。于是,我決定,我要重頭整理自己的知識框架,把那些只是似曾相似的技術,原理全部吃透,于是我從網上找了一份“前端知識架構圖譜”,決定按照里面的提綱,全部重新學習一遍。只是我再次學習不能是簡單的看看書,看看博客,看看視頻就行了,這種事情我以前干過了,作為一個有幾年工作經驗的前端,我對自己有更高的要求:所有學過的知識點,必須自己全部寫成文章進行鞏固;所有框架的學習,必須學到原理或者源碼層面!

所以,“寫什么”這個問題的答案已經有了:學習前端知識架構,將學習過程寫成文章。

怎么寫

上面說了,我其實并沒有什么寫作經驗,我最近一次寫作是大學論文,再往前就是高中作文了,寫作水平其實不咋地。但是技術寫作跟普通作文不一樣,一般不需要華麗的辭藻,更重要的是要把問題講清楚,看技術文章的讀者需要的是學習技術知識,而不是看風花雪月,所以技術文章的邏輯,層級遞進,由淺入深,好理解其實更重要。我剛開始時也不知道怎么寫,也是在不斷寫作工程中,一邊寫,一邊總結,整體來說,我自己的文章其實都分了好幾個階段:

  1. 就是記個筆記
  2. 有自己理解的知識點解析
  3. 深入源碼,探究原理
  4. 從工作中總結

就是記個筆記

從小學開始,老師就會讓大家記筆記,大家應該都會,這也是最簡單的切入點。我剛開始的時候,不會寫文章,寫的基本都是筆記,比如各種CSS居中方案,這就是我在其他地方學的,然后把他記錄下來,也就是個筆記而已。對于“CSS居中”這種問題來說,面試問爛了,網上資料也是一大堆,這篇文章也沒什么出彩的地方,所以關注的人不多。其實對于“筆記型”來說,獲取關注少是很容易理解的,因為你寫的東西是筆記,也就是說你也是從其他地方學來的,整個文章的思路其實也是人家的,如果自己記筆記的水平不高,可能寫出來的效果還不如原文章。

有自己理解的知識點解析

在寫了一些“筆記型”文章后,我發現效果不好,不僅僅是沒什么人關注,甚至對自己幫助也不大。經常是寫了沒多久就忘了,需要的時候還要回過頭來看看筆記,我開始意識到,這個現象的本質是,你寫的東西是筆記,核心思想都是人家的,或者是自己東拼西湊的,整篇文章沒有自己的邏輯,沒有自己的見解。于是,我開始嘗試在文章中加入自己的見解,當時正好組內有小伙伴對“JS原型鏈”理解的不是很透徹,網上雖然有很多類似文章,但是很多都是從表面來解釋“原型鏈是什么”,畫的圖也很復雜,不是很好理解。于是我嘗試自己寫一篇原型鏈的文章,因為我知道他可以實現“面向對象”的特征,這是很多其他文章都沒怎么提的,但卻是設計者最初可能想要實現的效果,于是我類比Java的面向對象,從面向對象的角度講述了原型鏈的作用以及他存在的意義,就是這個:輕松理解JS中的面向對象,順便搞懂prototype和__proto__。這篇文章上了掘金首頁推薦,最終獲得了兩百多贊,一萬多閱讀,這讓我開始意識到,“有自己理解的知識點解析”在掘金可能更受歡迎。

在這之后,我開始有意識的在整理知識架構時加入自己的見解。那對于一個知識點,怎么產生自己的見解呢?這需要在學習時多問自己幾個問題!比如,學習HTTPS時,除了跟大家一樣搞清楚HTTPS的加解密流程,握手過程外,我問了自己一個問題:“HTTPS有沒有可能被破解?假如我是個黑客,如果我想破解HTTPS,有哪些方法和途徑?”帶著這個問題,我從“破解HTTPS”的角度講述了HTTPS的原理,這篇文章也上了推薦,獲得了一百多贊和好幾千閱讀:RSA初探,聊聊怎么破解HTTPS。

嘗到點甜頭后,我更加注意在學習中反問自己問題,加入自己理解了。有時候在學習別人的東西時,我發現了別人沒發現的一些點,也可以從這個角度加入自己的獨到見解,寫成自己的文章,比如某視頻課程在講述JS的事件循環時說:“setImmediatesetTimeout先執行”。聽到這句話,我敏銳的感覺不太對,因為我曾經遇到過setTimeoutsetImmediate先執行的情況,但是具體是啥情況我一時想不起來。于是我花了點時間把這個問題和原理徹底弄清楚了,并寫成了自己的文章:setTimeout和setImmediate到底誰先執行,本文讓你徹底理解Event Loop。這篇文章最終也獲得了一百多贊,大幾千閱讀~

深入源碼,探究原理

JS知識體系雖然龐大,但是終究是有限的,很快我就寫了十幾篇JS的文章,內容包含了內存管理,深淺拷貝,面向對象(原型鏈),this指向,事件循環,變量類型,作用域等等。這些已經囊括了JS的主要知識點,JS上我已經很難找到新的寫文章的點了。

于是我的文章內容開始轉向我使用的框架,這幾年我主要使用的React技術棧。于是我準備重新整理學習React技術棧,當然不是學習他的用法了,畢竟我用了幾年了,用法早就熟悉了,這次我要學的是他們的源碼和原理。源碼和原理相對于JS知識和框架使用方法來說要難得多,受眾也小的多,對于讀者來說也很難產生直接的收益。因為讀者可能看個JS知識點,出去面試就能應付大部分的JS面試了,除了些大廠外,也不是每個公司面試都會問源碼,而且這些受歡迎的開源庫是各位大牛努力寫作的成果,里面匯聚了各種JS的高級用法,各種高級編程思想和設計模式,所以即使我盡量寫得深入淺出,層層遞進,相較于其他文章來說仍然會顯得更加晦澀難讀。所以這類文章在掘金獲得的贊和閱讀并不可觀,我大量的源碼解析都只有三四十個贊,這里面還有一半左右是我厚臉求朋友同事們點的(這點我后面在講推廣的時候會說)。

對于作者來說,寫源碼類文章需要去讀框架源碼,也會很花時間。我寫一個JS知識點的文章,因為東西都是我熟悉的,可能幾天就搞定了,寫完了還會有上百的贊。但是一個復雜框架的源碼解析,比如Express.js,我需要一點點的去讀,去調試源碼,成文可能需要兩三周,寫完后可能仍然只有三四十個贊。從社區聲望增長這個角度來說,性價比極低!但是我一直沒有放棄這類文章,甚至現在成了我主要的寫作方向。為什么?因為人總要突破自己的舒適區,探索未知的領域,最終才能學習到東西,獲得成長!這其實回到了文章開頭就提出的問題:“你為什么要寫技術文章?”對于我來說,這是我學習的途徑,所以如果這個過程我能夠學到東西,能夠感受到成長,我就會堅持去做,即使他在其他方面性價比很低!另外我的源碼類文章雖然在掘金反響不是很好,但是在其他平臺,比如思否,還可以,所以其實也是有回報的。

好了,說了這么多為什么要寫源碼解析,現在來談談怎么寫源碼解析。前面說了,在我從事技術寫作之前,我基本不懂源碼,是名副其實的“API工程師”,那會兒我也是一提到源碼就心慌,完全不知道從何下手。后來我忐忑的打破自己的心理障礙,多次嘗試之后找到了一個看源碼的套路。其實再??的框架或庫本質也是JS代碼,所以我們可以用一種簡單質樸的方法去讀,這其實也是大家經常在用的方法。想象這樣一個場景,你們公司一個運行很久的項目出了點問題,你領導讓你去調查下。由于這個項目你之前沒有參與,現在貿然叫你去解決BUG,你是不是要先反復復現問題,然后找到相關的代碼塊,調試這些代碼并找到BUG原因,然后將它修復??丛创a的時候我們完全可以用類似的思路去看,先縮小范圍,只看這個庫的核心代碼。比如Koa.js核心用法其實只有這么點:

const Koa = require('koa');
const app = new Koa();

app.use(async ctx => {
  ctx.body = 'Hello World';
});

app.listen(3000);

你就把它當成你現在需要接手的老項目,為了弄懂他的運行邏輯,看看這段代碼里面他暴露了哪些API,然后一個一個去調試下就行了。就上面這幾行代碼而言,其實只有三個API:

  1. Koa
  2. app.use實例方法
  3. app.listen實例方法

花點時間去源碼里面找到這三個API,并看看他們是怎么實現的,在看的時候,把主要邏輯剝離出來,自己實現一遍,同時把整個過程記錄下來。等你把這三個API實現都看懂了,其實一篇源碼解析的文章也就出來了,同時還可以產出一個迷你版Koa手寫Koa.js源碼。

從工作中總結

其實很多公司都不是技術驅動的,技術只是實現業務的一個手段而已,這就造成很多公司的一個項目都是從另一個項目抄抄改改就能用,很多人(包括我)在這種環境下待久了,慢慢就成了“API工程師”,最熟悉的其實是CV大法。但是每個公司的業務其實在某方面都有自己一定的獨到性,不然也活不下來,在實現這些比較復雜的業務時,有時候我們也會用一些比較有意思的方法,有時候我們可能花了很多時間去實現他,但是過后又慢慢淡忘了。其實對于這些有一定技術含量的工作,我們也可以總結下,然后寫成文章,慢慢沉淀下來成為自己的技術。我就從工作中總結了三篇文章,有的反響還不錯:

  1. 速度提高幾百倍,記一次數據結構在實際工作中的運用
  2. 使用mono-repo實現跨項目組件共享
  3. 歪門邪道性能優化:魔改三方庫源碼,性能提高幾十倍!

推廣

有句俗話說:“酒香不怕巷子深”。但是這個并不適用于現在的互聯網時代,互聯網時代是信息爆炸的時代,如果沒有適度的推廣,即使你的內容很好,最終也會淹沒在信息的洪流里面。好內容的推廣對于社區,讀者和作者來說其實是三贏的。

對于社區來說,如果有大量優質內容提供給讀者,口碑就會很好,讀者會愿意長期待在這個社區學習,并可能會主動推薦給朋友。所以很多社區的編輯很大一部分工作就是主動發掘好的內容,并推送給更多的用戶。

對于讀者來說,好內容的推廣可以學習到更多東西,而不是整天被一些低質量內容霸屏。

對于作者來說,好內容的推廣可以獲得更多關注,更多的社區聲望,激發創作熱情,從而形成正向激勵,產出更多高質量內容。

但是推廣有一個很大的前提:推廣的內容一定要是高質量的內容,不然會起反效果。

所以我的技術寫作,我也嘗試了多種推廣方式和渠道,不同的方式效果不一樣,我用過的方式主要有:

  1. 各種QQ群,微信群分享
  2. 找朋友,同事幫忙點贊
  3. 找社區編輯幫忙推薦
  4. 多平臺發布
  5. 文章相互引用
  6. 運營微信公眾號

下面就這些詳細講述下:

推廣的前提是高質量

在推廣之前,一定要確保你推廣的內容的質量,至少要是你用心寫的,也許你現在只是一個初學者,寫不出高深內容,但是你寫的內容一定要是你用心寫的,要讓讀者感受到你的誠意。如果只是簡單的面試題拼湊,甚至里面的答案都是錯的,你還拼命去推廣,你推廣的越多,只會讓更多人知道你寫的東西不好,沒誠意,可能還會被很多人留言懟。就像開頭提到的那個例子,如果一味的為了“刷聲望”而去拼湊內容,大量推廣,你聲望可能會漲得很快,但是,同時也會讓大量的人知道,你寫的東西不行,沒誠意,甚至可能被大佬懟到不敢露面。

QQ群,微信群分享

我開始寫文章時喜歡寫完了就分享到一些QQ群和微信群,但是效果并不好。經常是分享到一個幾百人的群,過一會兒去看,閱讀量漲了幾百,但是贊一個沒有。。。當然也可能是我早期的“筆記型”文章質量不高,所以獲贊不多,比如前面提到過的各種CSS居中方案,我就分享到過很多群,最終有三千多閱讀,但是贊只有三十來個。。。所以我現在已經基本不亂分享了,收益太低,還可能被當成打廣告的遭嫌棄。

找朋友,同事幫忙點贊

這條主要是針對掘金平臺的,因為掘金的贊多了可以升級,升到4級就是“優秀作者”,可以自動上首頁。所以我在掘金發布后,會分享給關系好的同事和同學,因為關系很好,他們基本都會幫忙點個??。但是這部分老鐵人不多,總共也就十幾個。

找社區編輯推薦

這其實是效果最好的一個推廣渠道,可以聯系社區編輯,將寫好的文章鏈接發給他,編輯在審核后,覺得可以的會推薦到社區首頁,這會大大提高曝光量。以掘金為例,一般我上首頁推薦的文章,至少都會有十來個贊,閱讀少說幾百上千。加上前面朋友點的贊,我一篇文章最少會有三十來個贊,加上閱讀量轉換的掘力值,一篇文章至少會有四五十的掘力值。有一段時間,我就以這個為基準在那里算:我再寫一百篇就可以升4級了,哈哈?? 當然如果出了爆品,某篇文章獲得了成百上千的贊,會大大加速這個進程。

其他社區,比如思否,開源中國,找編輯推薦效果也是非常好的,他們有作者推薦群,可以聯系編輯加群,有好的內容就可以發到群里求推薦。

多平臺發布

中文社區其實還是挺多的,我最開始是在掘金寫文章,但是粉絲最多的平臺卻是思否,個人粉絲將近兩千,專欄粉絲一萬多。所以你文章寫好后,可以發布到多個平臺,也許這個平臺不火的文章在另一個平臺卻火了。目前對于我來說效果還不錯的平臺有:掘金,思否,開源中國和博客園,下面我就這幾個平臺的特點來細說下:

掘金

掘金最大的特色是等級制度,等級到4級可以解鎖成就:掘金優秀作者,然后發布的文章可以自動上首頁,可以大大提高曝光量。另外編輯也很負責,會主動尋找優質內容推薦到首頁,所以如果你持續輸出優質內容,篇篇被推薦也是有可能的。

思否

思否最大的特點是漲粉很快,因為新用戶在注冊思否時會推薦一些專欄和作者給他關注,如果你足夠活躍,就可以進這個推薦列表。思否每年還會評定“Top Writer”,每年15人,因為名額少,所以比掘金的“優秀作者”還難點,如果被評上了“Top Writer”,會有一段時間的流量支持,漲粉更快,我評上后最多的一天漲粉上千。另外思否的技術團隊也很負責,有什么問題在群里反饋了很快就能得到回答,有時候CEO還會親自回復??。

開源中國

開源中國流量也不錯,如果被推薦上首頁,至少會有一兩千的閱讀。另外在他的博客站點首頁還有個“精彩博客”欄目,如果出現在這里,可以掛很長時間,下圖中這篇文章:速度提高幾百倍,記一次數據結構在實際工作中的運用是我1月6號發布的,到今天,2月23號,一個多月了還排在這個欄目第一,單篇閱讀一直在漲,已經有4.7萬了。

image-20210222165101368

而同樣一篇文章我也發布到過掘金,只有三十來個贊,效果很一般,所以多平臺發布還是有好處的,這個平臺不火,另一個平臺說不定就火了。

博客園

博客園最大的特色是在發布時可以自己選擇上首頁,當然如果你質量不好,還是可能會被編輯撤下來的,我以前就被撤下來過。因為可以自己決定上首頁,所以博客園的首頁刷新很快,一會兒就被淹沒了,所以單篇閱讀量不高,可能只有一兩百。但是如果你能獲得編輯的特別推薦,出現在這個位置,流量還是可以的:

image-20210222170001673

我有兩篇獲得過編輯推薦,最多的一篇有近萬閱讀,少的也有三四千,這個位置只能待一天,所以其實還是不錯了。

另外,我還試過CSDN,知乎,騰訊云社區等,因為效果不是很好,已經沒怎么運營了。大家早期時可以盡量多發布幾個平臺,然后看看哪個平臺效果好就重點關注,效果不好的就可以放棄了,因為運營平臺過多也會耗費大量精力,選性價比高的弄就行。

文章內相互引用

因為我寫的東西成體系,所以一篇文章B可能會用到以前寫的文章A的知識,那我就會在文章B里面引用文章A,這樣讀者可能就順著去看文章A了。這樣有一定的效果,有時候很久前寫的文章會被點贊,就是這么來的。

運營微信公眾號

我寫了一段時間后,會有朋友給我留言,希望轉載到微信公眾號,這種情況遇到幾次后,我就在想,我為啥不自己弄個公眾號,于是我就開通了一個公眾號進擊的大前端。聽說微信公眾號還能賺錢,說不定我還能賺點外快,到目前為止確實有一點點收入:

  1. 獲得贊賞收入7元,其中5元是我老婆給的
  2. 獲得廣告收入1.84元

這個收入還真是一點點??,主要是因為我運營比較佛系,發的內容主要是原創,粉絲不多,新增粉絲主要是文章后面的廣告和其他號主轉發帶來的。廣告我也只放了文末廣告,文中廣告都沒放,怕影響用戶體驗。

有一段時間我也想過要不要大力運營,每天轉發更新內容,但是每天發內容需要尋找稿子,審核稿子,也需要不少時間。而我目前的主要精力在學習和原創內容上,就沒弄了,先佛系運營著吧。

總結

本文總結和分享了我這一年從事技術寫作的心得體會,對這一年進行了回顧,同時也希望給想往這方面發展的朋友提供一個參考。下面再對內容進行一個簡短總結:

  1. 從事技術寫作的目的最好是學習和分享,而不是單純的刷聲望。
  2. 寫作內容可以是:

    1. 簡單的學習筆記:因為是簡單的記錄別人的內容,效果可能不是很好
    2. 有自己理解的知識點解析:有自己見解,也有一定難度,但又不至于晦澀難懂,受眾廣,在社區容易受歡迎。
    3. 原理和源碼解析:內容較難,受眾略小,在社區不一定受歡迎,但是對于自己的成長非常有用。
    4. 從工作中總結:注意總結工作中有價值的技術內容,而不是做單純的“API工程師”,在工作中完成技術沉淀,一舉兩得。
  3. 適度的推廣是社區,讀者和作者的三贏,但是推廣的內容一定要是高質量的,不然可能會起反效果,一般推廣手段有:

    1. 各種群分享:效果不好,經常是閱讀量漲幾百,贊一個沒有
    2. 分享給朋友,同事:早期有用,可以保底有幾個贊,但是數量畢竟有限。
    3. 社區編輯推薦:最有用的方式,可以大幅提高曝光量,但是質量一定要過關才行。
    4. 多平臺發布:寫了文章后可以嘗試發到多個平臺,也許這個平臺不火的另一個平臺火了。
    5. 文章內相互引用:有一點效果,可以讓很久前寫的文章仍然獲得少量曝光。
    6. 運營微信公眾號:據說能賺錢,但是我佛系運營,目前總收入不到10塊。

最后感謝各位讀者的閱讀,點贊!

感謝各位公眾號號主的轉發!

感謝掘金,思否,開源中國,博客園等平臺的大力支持!

你們的支持一直是我持續創作的動力!

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

查看原文

贊 13 收藏 3 評論 2

蔣鵬飛 發布了文章 · 2月19日

手寫一個webpack,看看AST怎么用

本文開始我會圍繞webpackbabel寫一系列的工程化文章,這兩個工具我雖然天天用,但是對他們的原理理解的其實不是很深入,寫這些文章的過程其實也是我深入學習的過程。由于webpackbabel的體系太大,知識點眾多,不可能一篇文章囊括所有知識點,目前我的計劃是從簡單入手,先實現一個最簡單的可以運行的webpack,然后再看看plugin, loadertree shaking等功能。目前我計劃會有這些文章:

  1. 手寫最簡webpack,也就是本文
  2. webpackplugin實現原理
  3. webpackloader實現原理
  4. webpacktree shaking實現原理
  5. webpackHMR實現原理
  6. babelast原理

所有文章都是原理或者源碼解析,歡迎關注~

本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

注意:本文主要講webpack原理,在實現時并不嚴謹,而且只處理了importexportdefault情況,如果你想在生產環境使用,請自己添加其他情況的處理和邊界判斷。

為什么要用webpack

筆者剛開始做前端時,其實不知道什么webpack,也不懂模塊化,都是html里面直接寫script,引入jquery直接干。所以如果一個頁面的JS需要依賴jquerylodash,那html可能就長這樣:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <script data-original="https://unpkg.com/jquery@3.5.1"></script>
    <script data-original="https://unpkg.com/lodash@4.17.20"></script>
    <script data-original="./src/index.js"></script>
  </head>
  <body>
  </body>
</html>

這樣寫會導致幾個問題:

  1. 單獨看index.js不能清晰的找到他到底依賴哪些外部庫
  2. script的順序必須寫正確,如果錯了就會導致找不到依賴,直接報錯
  3. 模塊間通信困難,基本都靠往window上注入變量來暴露給外部
  4. 瀏覽器嚴格按照script標簽來下載代碼,有些沒用到的代碼也會下載下來
  5. 當前端規模變大,JS腳本會顯得很雜亂,項目管理混亂

webpack的一個最基本的功能就是來解決上述的情況,允許在JS里面通過import或者require等關鍵字來顯式申明依賴,可以引用第三方庫,自己的JS代碼間也可以相互引用,這樣在實質上就實現了前端代碼的模塊化。由于歷史問題,老版的JS并沒有自己模塊管理方案,所以社區提出了很多模塊管理方案,比如ES2015import,CommonJSrequire,另外還有AMD,CMD等等。就目前我見到的情況來說,import因為已經成為ES2015標準,所以在客戶端廣泛使用,而requireNode.js的自帶模塊管理機制,也有很廣泛的用途,而AMDCMD的使用已經很少見了。

但是webpack作為一個開放的模塊化工具,他是支持ES6,CommonJSAMD等多種標準的,不同的模塊化標準有不同的解析方法,本文只會講ES6標準的import方案,這也是客戶端JS使用最多的方案。

簡單例子

按照業界慣例,我也用hello world作為一個簡單的例子,但是我將這句話拆成了幾部分,放到了不同的文件里面。

先來建一個hello.js,只導出一個簡單的字符串:

const hello = 'hello';

export default hello;

然后再來一個helloWorld.js,將helloworld拼成一句話,并導出拼接的這個方法:

import hello from './hello';

const world = 'world';

const helloWorld = () => `${hello} ${world}`;

export default helloWorld;

最后再來個index.js,將拼好的hello world插入到頁面上去:

import helloWorld from "./helloWorld";

const helloWorldStr = helloWorld();

function component() {
  const element = document.createElement("div");

  element.innerHTML = helloWorldStr;

  return element;
}

document.body.appendChild(component());

現在如果你直接在html里面引用index.js是不能運行成功的,因為大部分瀏覽器都不支持import這種模塊導入。而webpack就是來解決這個問題的,它會將我們模塊化的代碼轉換成瀏覽器認識的普通JS來執行。

引入webpack

我們印象中webpack的配置很多,很麻煩,但那是因為我們需要開啟的功能很多,如果只是解析轉換import,配置起來非常簡單。

  1. 先把依賴裝上吧,這沒什么好說的:

    // package.json
    {
      "devDependencies": {
        "webpack": "^5.4.0",
        "webpack-cli": "^4.2.0"
      },
    }
  2. 為了使用方便,再加個build腳本吧:

    // package.json
    {
      "scripts": {
        "build": "webpack"
      },
    }
  3. 最后再簡單寫下webpack的配置文件就好了:

    // webpack.config.js
    
    const path = require("path");
    
    module.exports = {
      mode: "development",
      devtool: 'source-map',
      entry: "./src/index.js",
      output: {
        filename: "main.js",
        path: path.resolve(__dirname, "dist"),
      },
    };

    這個配置文件里面其實只要指定了入口文件entry和編譯后的輸出文件目錄output就可以正常工作了,這里這個配置的意思是讓webpack./src/index.js開始編譯,編譯后的文件輸出到dist/main.js這個文件里面。

    這個配置文件上還有兩個配置modedevtool只是我用來方便調試編譯后的代碼的,mode指定用哪種模式編譯,默認是production,會對代碼進行壓縮和混淆,不好讀,所以我設置為development;而devtool是用來控制生成哪種粒度的source map,簡單來說,想要更好調試,就要更好的,更清晰的source map,但是編譯速度變慢;反之,想要編譯速度快,就要選擇粒度更粗,更不好讀的source map,webpack提供了很多可供選擇的source map,具體的可以看他的文檔。

  4. 然后就可以在dist下面建個index.html來引用編譯后的代碼了:

    // index.html
    
    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="utf-8" />
      </head>
      <body>
        <script data-original="main.js"></script>
      </body>
    </html>
  5. 運行下yarn build就會編譯我們的代碼,然后打開index.html就可以看到效果了。

    image-20210203154111168

深入原理

前面講的這個例子很簡單,一般也滿足不了我們實際工程中的需求,但是對于我們理解原理卻是一個很好的突破口,畢竟webpack這么龐大的一個體系,我們也不能一口吃個胖子,得一點一點來。

webpack把代碼編譯成了啥?

為了弄懂他的原理,我們可以直接從編譯后的代碼入手,先看看他長啥樣子,有的朋友可能一提到去看源碼,心理就沒底,其實我以前也是這樣的。但是完全沒有必要懼怕,他編譯后的代碼瀏覽器能夠執行,那肯定就是普通的JS代碼,不會藏著這么黑科技。

下面是編譯完的代碼截圖:

image-20210203155553091

雖然我們只有三個簡單的JS文件,但是加上webpack自己的邏輯,編譯后的文件還是有一百多行代碼,所以即使我把具體邏輯折疊起來了,這個截圖還是有點長,為了能夠看清楚他的結構,我將它分成了4個部分,標記在了截圖上,下面我們分別來看看這幾個部分吧。

  1. 第一部分其實就是一個對象__webpack_modules__,這個對象里面有三個屬性,屬性名字是我們三個模塊的文件路徑,屬性的值是一個函數,我們隨便展開一個./src/helloWorld.js看下:

    image-20210203161613636

    我們發現這個代碼內容跟我們自己寫的helloWorld.js非常像:

    image-20210203161902647

    他只是在我們的代碼前先調用了__webpack_require__.r__webpack_require__.d,這兩個輔助函數我們在后面會看到。

    然后對我們的代碼進行了一點修改,將我們的import關鍵字改成了__webpack_require__函數,并用一個變量_hello__WEBPACK_IMPORTED_MODULE_0__來接收了import進來的內容,后面引用的地方也改成了這個,其他跟這個無關的代碼,比如const world = 'world';還是保持原樣的。

    這個__webpack_modules__對象存了所有的模塊代碼,其實對于模塊代碼的保存,在不同版本的webpack里面實現的方式并不一樣,我這個版本是5.4.0,在4.x的版本里面好像是作為數組存下來,然后在最外層的立即執行函數里面以參數的形式傳進來的。但是不管是哪種方式,都只是轉換然后保存一下模塊代碼而已。

  2. 第二塊代碼的核心是__webpack_require__,這個代碼展開,瞬間給了我一種熟悉感:

    image-20210203162542359

    來看一下這個流程吧:

    1. 先定義一個變量__webpack_module_cache__作為加載了的模塊的緩存
    2. __webpack_require__其實就是用來加載模塊的
    3. 加載模塊時,先檢查緩存中有沒有,如果有,就直接返回緩存
    4. 如果緩存沒有,就從__webpack_modules__將對應的模塊取出來執行
    5. __webpack_modules__就是上面第一塊代碼里的那個對象,取出的模塊其實就是我們自己寫的代碼,取出執行的也是我們每個模塊的代碼
    6. 每個模塊執行除了執行我們的邏輯外,還會將export的內容添加到module.exports上,這就是前面說的__webpack_require__.d輔助方法的作用。添加到module.exports上其實就是添加到了__webpack_module_cache__緩存上,后面再引用這個模塊就直接從緩存拿了。

    這個流程我太熟悉了,因為他簡直跟Node.jsCommonJS實現思路一模一樣,具體的可以看我之前寫的這篇文章:深入Node.js的模塊加載機制,手寫require函數。

  3. 第三塊代碼其實就是我們前面看到過的幾個輔助函數的定義,具體干啥的,其實他的注釋已經寫了:

    1. __webpack_require__.d:核心其實是Object.defineProperty,主要是用來將我們模塊導出的內容添加到全局的__webpack_module_cache__緩存上。

      image-20210203164427116

    2. __webpack_require__.o:其實就是Object.prototype.hasOwnProperty的一個簡寫而已。

      image-20210203164450385

    3. __webpack_require__.r:這個方法就是給每個模塊添加一個屬性__esModule,來表明他是一個ES6的模塊。

      image-20210203164658054

    4. 第四塊就一行代碼,調用__webpack_require__加載入口模塊,啟動執行。

這樣我們將代碼分成了4塊,每塊的作用都搞清楚,其實webpack干的事情就清晰了:

  1. import這種瀏覽器不認識的關鍵字替換成了__webpack_require__函數調用。
  2. __webpack_require__在實現時采用了類似CommonJS的模塊思想。
  3. 一個文件就是一個模塊,對應模塊緩存上的一個對象。
  4. 當模塊代碼執行時,會將export的內容添加到這個模塊對象上。
  5. 當再次引用一個以前引用過的模塊時,會直接從緩存上讀取模塊。

自己實現一個webpack

現在webpack到底干了什么事情我們已經清楚了,接下來我們就可以自己動手實現一個了。根據前面最終生成的代碼結果,我們要實現的代碼其實主要分兩塊:

  1. 遍歷所有模塊,將每個模塊代碼讀取出來,替換掉importexport關鍵字,放到__webpack_modules__對象上。
  2. 整個代碼里面除了__webpack_modules__和最后啟動的入口是變化的,其他代碼,像__webpack_require__,__webpack_require__.r這些方法其實都是固定的,整個代碼結構也是固定的,所以完全可以先定義好一個模板。

使用AST解析代碼

由于我們需要將import這種代碼轉換成瀏覽器能識別的普通JS代碼,所以我們首先要能夠將代碼解析出來。在解析代碼的時候,可以將它讀出來當成字符串替換,也可以使用更專業的AST來解析。AST全稱叫Abstract Syntax Trees,也就是抽象語法樹,是一個將代碼用樹來表示的數據結構,一個代碼可以轉換成AST,AST又可以轉換成代碼,而我們熟知的babel其實就可以做這個工作。要生成AST很復雜,涉及到編譯原理,但是如果僅僅拿來用就比較簡單了,本文就先不涉及復雜的編譯原理,而是直接將babel生成好的AST拿來使用。

注意:webpack源碼解析AST并不是使用的babel,而是使用的acorn。webpack自己實現了一個JavascriptParser類,這個類里面用到了acorn。本文寫作時采用了babel,這也是一個大家更熟悉的工具。

比如我先將入口文件讀出來,然后用babel轉換成AST可以直接這樣寫:

const fs = require("fs");
const parser = require("@babel/parser");

const config = require("../webpack.config"); // 引入配置文件

// 讀取入口文件
const fileContent = fs.readFileSync(config.entry, "utf-8");

// 使用babel parser解析AST
const ast = parser.parse(fileContent, { sourceType: "module" });

console.log(ast);   // 把ast打印出來看看

上面代碼可以將生成好的ast打印在控制臺:

image-20210207153459699

這雖然是一個完整的AST,但是看起來并不清晰,關鍵數據其實是body字段,這里的body也只是展示了類型名字。所以照著這個寫代碼其實不好寫,這里推薦一個在線工具https://astexplorer.net/,可以很清楚的看到每個節點的內容:

image-20210207154116026

從這個解析出來的AST我們可以看到,body主要有4塊代碼:

  1. ImportDeclaration:就是第一行的import定義
  2. VariableDeclaration:第三行的一個變量申明
  3. FunctionDeclaration:第五行的一個函數定義
  4. ExpressionStatement:第十三行的一個普通語句

你如果把每個節點展開,會發現他們下面又嵌套了很多其他節點,比如第三行的VariableDeclaration展開后,其實還有個函數調用helloWorld()

image-20210207154741847

使用traverse遍歷AST

對于這樣一個生成好的AST,我們可以使用@babel/traverse來對他進行遍歷和操作,比如我想拿到ImportDeclaration進行操作,就直接這樣寫:

// 使用babel traverse來遍歷ast上的節點
traverse(ast, {
  ImportDeclaration(path) {
    console.log(path.node);
  },
});

上面代碼可以拿到所有的import語句:

image-20210207162114290

import轉換為函數調用

前面我們說了,我們的目標是將ES6的import

import helloWorld from "./helloWorld";

轉換成普通瀏覽器能識別的函數調用:

var _helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");

為了實現這個功能,我們還需要引入@babel/types,這個庫可以幫我們創建新的AST節點,所以這個轉換代碼寫出來就是這樣:

const t = require("@babel/types");

// 使用babel traverse來遍歷ast上的節點
traverse(ast, {
  ImportDeclaration(p) {
    // 獲取被import的文件
    const importFile = p.node.source.value;

    // 獲取文件路徑
    let importFilePath = path.join(path.dirname(config.entry), importFile);
    importFilePath = `./${importFilePath}.js`;

    // 構建一個變量定義的AST節點
    const variableDeclaration = t.variableDeclaration("var", [
      t.variableDeclarator(
        t.identifier(
          `__${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__`
        ),
        t.callExpression(t.identifier("__webpack_require__"), [
          t.stringLiteral(importFilePath),
        ])
      ),
    ]);

    // 將當前節點替換為變量定義節點
    p.replaceWith(variableDeclaration);
  },
});

上面這段代碼我們用了很多@babel/types下面的API,比如t.variableDeclaration,t.variableDeclarator,這些都是用來創建對應的節點的,具體的API可以看這里。注意這個代碼里面我有很多寫死的地方,比如importFilePath生成邏輯,還應該處理多種后綴名的,還有最終生成的變量名_${path.basename(importFile)}__WEBPACK_IMPORTED_MODULE_0__,最后的數字我也是直接寫了0,按理來說應該是根據不同的import順序來生成的,但是本文主要講webpack的原理,這些細節上我就沒花過多時間了。

上面的代碼其實是修改了我們的AST,修改后的AST可以用@babel/generator又轉換為代碼:

const generate  = require('@babel/generator').default;

const newCode = generate(ast).code;
console.log(newCode);

這個打印結果是:

image-20210207172310114

可以看到這個結果里面import helloWorld from "./helloWorld";已經被轉換為var __helloWorld__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/helloWorld.js");。

替換import進來的變量

前面我們將import語句替換成了一個變量定義,變量名字也改為了__helloWorld__WEBPACK_IMPORTED_MODULE_0__,自然要將調用的地方也改了。為了更好的管理,我們將AST遍歷,操作以及最后的生成新代碼都封裝成一個函數吧。

function parseFile(file) {
  // 讀取入口文件
  const fileContent = fs.readFileSync(file, "utf-8");

  // 使用babel parser解析AST
  const ast = parser.parse(fileContent, { sourceType: "module" });

  let importFilePath = "";

  // 使用babel traverse來遍歷ast上的節點
  traverse(ast, {
    ImportDeclaration(p) {
      // 跟之前一樣的
    },
  });

  const newCode = generate(ast).code;

  // 返回一個包含必要信息的新對象
  return {
    file,
    dependcies: [importFilePath],
    code: newCode,
  };
}

然后啟動執行的時候就可以調這個函數了

parseFile(config.entry);

拿到的結果跟之前的差不多:

image-20210207173744463

好了,現在需要將使用import的地方也替換了,因為我們已經知道了這個地方是將它作為函數調用的,也就是要將

const helloWorldStr = helloWorld();

轉為這個樣子:

const helloWorldStr = (0,_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default)();

這行代碼的效果其實跟_helloWorld__WEBPACK_IMPORTED_MODULE_0__.default()是一樣的,為啥在前面包個(0, ),我也不知道,有知道的大佬告訴下我唄。

所以我們在traverse里面加一個CallExpression

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟前面的差不多,省略了
    },
    CallExpression(p) {
      // 如果調用的是import進來的函數
      if (p.node.callee.name === importVarName) {
        // 就將它替換為轉換后的函數名字
        p.node.callee.name = `${importCovertVarName}.default`;
      }
    },
  });

這樣轉換后,我們再重新生成一下代碼,已經像那么個樣子了:

image-20210207175649607

遞歸解析多個文件

現在我們有了一個parseFile方法來解析處理入口文件,但是我們的文件其實不止一個,我們應該依據模塊的依賴關系,遞歸的將所有的模塊都解析了。要實現遞歸解析也不復雜,因為前面的parseFile的依賴dependcies已經返回了:

  1. 我們創建一個數組存放文件的解析結果,初始狀態下他只有入口文件的解析結果
  2. 根據入口文件的解析結果,可以拿到入口文件的依賴
  3. 解析所有的依賴,將結果繼續加到解析結果數組里面
  4. 一直循環這個解析結果數組,將里面的依賴文件解析完
  5. 最后將解析結果數組返回就行

寫成代碼就是這樣:

function parseFiles(entryFile) {
  const entryRes = parseFile(entryFile); // 解析入口文件
  const results = [entryRes]; // 將解析結果放入一個數組

  // 循環結果數組,將它的依賴全部拿出來解析
  for (const res of results) {
    const dependencies = res.dependencies;
    dependencies.map((dependency) => {
      if (dependency) {
        const ast = parseFile(dependency);
        results.push(ast);
      }
    });
  }

  return results;
}

然后就可以調用這個方法解析所有文件了:

const allAst = parseFiles(config.entry);
console.log(allAst);

看看解析結果吧:

image-20210208152330212

這個結果其實跟我們最終需要生成的__webpack_modules__已經很像了,但是還有兩塊沒有處理:

  1. 一個是import進來的內容作為變量使用,比如

    import hello from './hello';
    
    const world = 'world';
    
    const helloWorld = () => `${hello} ${world}`;
  2. 另一個就是export語句還沒處理

替換import進來的變量(作為變量調用)

前面我們已經用CallExpression處理過作為函數使用的import變量了,現在要處理作為變量使用的其實用Identifier處理下就行了,處理邏輯跟之前的CallExpression差不多:

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟以前一樣的
    },
    CallExpression(p) {
            // 跟以前一樣的
    },
    Identifier(p) {
      // 如果調用的是import進來的變量
      if (p.node.name === importVarName) {
        // 就將它替換為轉換后的變量名字
        p.node.name = `${importCovertVarName}.default`;
      }
    },
  });

現在再運行下,import進來的變量名字已經變掉了:

image-20210208153942630

替換export語句

從我們需要生成的結果來看,export需要進行兩個處理:

  1. 如果一個文件有export default,需要添加一個__webpack_require__.d的輔助方法調用,內容都是固定的,加上就行。
  2. export語句轉換為普通的變量定義。

對應生成結果上的這兩個:

image-20210208154959592

要處理export語句,在遍歷ast的時候添加ExportDefaultDeclaration就行了:

  traverse(ast, {
    ImportDeclaration(p) {
      // 跟以前一樣的
    },
    CallExpression(p) {
            // 跟以前一樣的
    },
    Identifier(p) {
      // 跟以前一樣的
    },
    ExportDefaultDeclaration(p) {
      hasExport = true; // 先標記是否有export

      // 跟前面import類似的,創建一個變量定義節點
      const variableDeclaration = t.variableDeclaration("const", [
        t.variableDeclarator(
          t.identifier("__WEBPACK_DEFAULT_EXPORT__"),
          t.identifier(p.node.declaration.name)
        ),
      ]);

      // 將當前節點替換為變量定義節點
      p.replaceWith(variableDeclaration);
    },
  });

然后再運行下就可以看到export語句被替換了:

image-20210208160244276

然后就是根據hasExport變量判斷在AST轉換為代碼的時候要不要加__webpack_require__.d輔助函數:

const EXPORT_DEFAULT_FUN = `
__webpack_require__.d(__webpack_exports__, {
   "default": () => (__WEBPACK_DEFAULT_EXPORT__)
});\n
`;

function parseFile(file) {
  // 省略其他代碼
  // ......
  
  let newCode = generate(ast).code;

  if (hasExport) {
    newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
  }
}

最后生成的代碼里面export也就處理好了:

image-20210208161030554

__webpack_require__.r的調用添上吧

前面說了,最終生成的代碼,每個模塊前面都有個__webpack_require__.r的調用

image-20210208161321401

這個只是拿來給模塊添加一個__esModule標記的,我們也給他加上吧,直接在前面export輔助方法后面加點代碼就行了:

const ESMODULE_TAG_FUN = `
__webpack_require__.r(__webpack_exports__);\n
`;

function parseFile(file) {
  // 省略其他代碼
  // ......
  
  let newCode = generate(ast).code;

  if (hasExport) {
    newCode = `${EXPORT_DEFAULT_FUN} ${newCode}`;
  }
  
  // 下面添加模塊標記代碼
  newCode = `${ESMODULE_TAG_FUN} ${newCode}`;
}

再運行下看看,這個代碼也加上了:

image-20210208161721369

創建代碼模板

到現在,最難的一塊,模塊代碼的解析和轉換我們其實已經完成了。下面要做的工作就比較簡單了,因為最終生成的代碼里面,各種輔助方法都是固定的,動態的部分就是前面解析的模塊和入口文件。所以我們可以創建一個這樣的模板,將動態的部分標記出來就行,其他不變的部分寫死。這個模板文件的處理,你可以將它讀進來作為字符串處理,也可以用模板引擎,我這里采用ejs模板引擎:

// 模板文件,直接從webpack生成結果抄過來,改改就行
/******/ (() => { // webpackBootstrap
/******/     "use strict";
// 需要替換的__TO_REPLACE_WEBPACK_MODULES__
/******/     var __webpack_modules__ = ({
                <% __TO_REPLACE_WEBPACK_MODULES__.map(item => { %>
                    '<%- item.file %>' : 
                    ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => {
                        <%- item.code %>
                    }),
                <% }) %>
            });
// 省略中間的輔助方法
    /************************************************************************/
    /******/     // startup
    /******/     // Load entry module
// 需要替換的__TO_REPLACE_WEBPACK_ENTRY
    /******/     __webpack_require__('<%- __TO_REPLACE_WEBPACK_ENTRY__ %>');
    /******/     // This entry module used 'exports' so it can't be inlined
    /******/ })()
    ;
    //# sourceMappingURL=main.js.map

生成最終的代碼

生成最終代碼的思路就是:

  1. 模板里面用__TO_REPLACE_WEBPACK_MODULES__來生成最終的__webpack_modules__
  2. 模板里面用__TO_REPLACE_WEBPACK_ENTRY__來替代動態的入口文件
  3. webpack代碼里面使用前面生成好的AST數組來替換模板的__TO_REPLACE_WEBPACK_MODULES__
  4. webpack代碼里面使用前面拿到的入口文件來替代模板的__TO_REPLACE_WEBPACK_ENTRY__
  5. 使用ejs來生成最終的代碼

所以代碼就是:

// 使用ejs將上面解析好的ast傳遞給模板
// 返回最終生成的代碼
function generateCode(allAst, entry) {
  const temlateFile = fs.readFileSync(
    path.join(__dirname, "./template.js"),
    "utf-8"
  );

  const codes = ejs.render(temlateFile, {
    __TO_REPLACE_WEBPACK_MODULES__: allAst,
    __TO_REPLACE_WEBPACK_ENTRY__: entry,
  });

  return codes;
}

大功告成

最后將ejs生成好的代碼寫入配置的輸出路徑就行了:

const codes = generateCode(allAst, config.entry);

fs.writeFileSync(path.join(config.output.path, config.output.filename), codes);

然后就可以使用我們自己的webpack來編譯代碼,最后就可以像之前那樣打開我們的html看看效果了:

image-20210218160539306

總結

本文使用簡單質樸的方式講述了webpack的基本原理,并自己手寫實現了一個基本的支持importexportdefaultwebpack。

本文可運行代碼已經上傳GitHub,大家可以拿下來玩玩:https://github.com/dennis-jiang/Front-End-Knowledges/tree/master/Examples/Engineering/mini-webpack

下面再就本文的要點進行下總結:

  1. webpack最基本的功能其實是將JS的高級模塊化語句,importrequire之類的轉換為瀏覽器能認識的普通函數調用語句。
  2. 要進行語言代碼的轉換,我們需要對代碼進行解析。
  3. 常用的解析手段是AST,也就是將代碼轉換為抽象語法樹。
  4. AST是一個描述代碼結構的樹形數據結構,代碼可以轉換為AST,AST也可以轉換為代碼。
  5. babel可以將代碼轉換為AST,但是webpack官方并沒有使用babel,而是基于acorn自己實現了一個JavascriptParser。
  6. 本文從webpack構建的結果入手,也使用AST自己生成了一個類似的代碼。
  7. webpack最終生成的代碼其實分為動態和固定的兩部分,我們將固定的部分寫入一個模板,動態的部分在模板里面使用ejs占位。
  8. 生成代碼動態部分需要借助babel來生成AST,并對其進行修改,最后再使用babel將其生成新的代碼。
  9. 在生成AST時,我們從配置的入口文件開始,遞歸的解析所有文件。即解析入口文件的時候,將它的依賴記錄下來,入口文件解析完后就去解析他的依賴文件,在解析他的依賴文件時,將依賴的依賴也記錄下來,后面繼續解析。重復這種步驟,直到所有依賴解析完。
  10. 動態代碼生成好后,使用ejs將其寫入模板,以生成最終的代碼。
  11. 如果要支持require或者AMD,其實思路是類似的,最終生成的代碼也是差不多的,主要的差別在AST解析那一塊。

參考資料

  1. babel操作AST文檔
  2. webpack源碼
  3. webpack官方文檔

文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

查看原文

贊 41 收藏 34 評論 10

蔣鵬飛 贊了文章 · 2月4日

若川的2020年度總結,水波不興

文章首發于我的公眾號「若川視野」《若川的2020年度總結,水波不興》

前言

2014年開始,每一年都會寫年度總結,堅持了6個年頭。


回顧2014,約定2015(QQ空間日志)
2015年總結,淡化舊標簽,無懼未來(QQ空間日志)
2016年度總結,畢業工作
2017年度總結,一如既往
2018年度總結,平淡無奇
2019年度總結,波瀾不驚
2020年度總結,水波不興(本文)


如今第7年了,最近總是想著2020年度總結的點點滴滴,思考這一年要寫點什么不一樣的,可是思前想后覺得這一年好像也沒有什么不一樣啊。2019年度總結文章中就寫了2020年度總結的標題可能取名為「水波不興」。內心深處就感覺水波不興不錯??赡苁莾刃纳钐幱袀€湖,湖面平靜,有時會激起水花和波浪,但終究會恢復平靜,也就是說平平淡淡是常態。年度總結往年基本是元旦3天假期就動筆了。


說起跨年和元旦,回憶起這幾年的最后一天。
把時間撥回到2016年12月31日,那時同行四人去了良渚博物館。
把時間撥回到2017年12月31日,那時幾個在杭州的大學同學一起聚餐、K歌。
把時間撥回到2018年12月31日,幾個同事一起去西湖邊跨年,最后同事開車把我們送回。之后聽到朋友說他們去西湖邊跨年,人太多打不到車,最后凌晨四五點才回去,說再也不去西湖邊跨年了。
時間再撥回到2019年12月31日,有了以往的“經驗”,跨年的氣息也沒有那么濃厚,就自己在住處跨過了一年。第二天元旦,朋友開車帶我去塘棲古鎮逛了逛,開啟了2020年。

杭州塘棲古鎮夕陽西下,開啟2020年

記得遠在2019年12月份,那時我在微博上看到武漢發生不明肺炎。2020年,1月20日回家過春節時,看到各種群里有討論要口罩,當時沒想那么多也來不及買口罩,于是沒有戴口罩回到了家。誰也不曾想,疫情會影響全世界。
2020年12月31日,也是在住處跨過了一年。不平凡的一年過去了。

回想這幾年,感嘆時光飛逝,每一年都過得普普通通。遠不及朋友圈各種大佬的一年。

個人是比較建議讀者朋友們都寫年度總結的,不一定要發布出來,給自己看也不錯,或者給未來的自己看興許也是一種回顧。非常欣慰有讀者朋友特意到我的博客看有沒有寫年度總結,也有讀者朋友在公眾號留言說看完了我往年的年度總結,想看我的2020年的??扇缃駥懗鰜砹?,怕是要對不住他們如此期待了。


2016年學習了一門年度計劃的課程,提到人生的8個方面,分別是身體健康、財務理財、人際社群、工作事業、家庭生活、學習成長、體驗突破、休閑放松。覺得這8方面還是挺合理的,于是從2016年度總結開始,都是按這8個方面來寫年度總結。另外作為讀者朋友的你也可以搜索微信小程序:滴答目標九宮格,看到的就是這八方面。

滴答目標九宮格圖

,也可以看鄒小強老師的這篇文章《小工具:隨時都可以看到漂亮的目標板》。

身體 · 健康

這一年,基本是走路上下班,姑且算是一種鍛煉吧。
這一年,視力又下降了。在2018年度總結中寫過的一句話引用過來同樣適用。正印證了那句話:真的要少玩手機電腦了,眼睛越來越不好了,打開支付寶都看不到錢了。
這一年,沒有難以入睡的記錄。而2019年記錄有12次輾轉反側,難以入睡。
身體健康重要性大家都知道,但往往是大部分作為年輕人的我們存在不良作息習慣和飲食習慣等,長此以往可能會導致身體一些問題。而身體健康、財務理財、人際社群可以看成是人生基石三要素。

財務 · 理財

年度總結里一直說理財,實際上很少理財??吹脚笥讶νg人炒股副業一年都賺了30w+,實名羨慕啊。不過仔細一想,別人肯定也是積累了很久和付出了很大的努力??此圃频L輕,但事實上背后的過程我們沒有看到??吹絼e人年度總結中的一句話:投資不追求暴利,年化10%~20%即可,相信復利的力量,關鍵在于堅持,10年內投資只是副業,10年后希望可以靠投資實現財務自由。

人際 · 社群

這一年,線下見過面的人屈指可數,總共見過兩個大學校友,兩個前端小伙伴(我微信群里的)。工作后除了公司同事,其他朋友線下見面,基本都是以一年為單位,一年到頭線下見面的人真的很少。真正比較長期聊過天的人數也是很少。正所謂:越長大,越孤獨。

工作 · 事業

我的年度總結很少寫公司工作方面。本職工作就是某不知名小公司的一名小前端開發工程師,負責小程序和網站開發等。
這一年,在工作方面,由于公司變動,我擔任了前端開發的面試官,負責給公司招人。記得2017年第一次面試別人時,自己都是很緊張的。當初剛畢業時求職面試,如今身份轉換,時間真快。
wakatime2020年使用Vscode編碼時間統計,總共1572小時,和2019年相比基本持平。平均每天5個多小時在使用編輯器VSCode,其中2月13日最多,竟然11小時16分鐘,記得這一天在家遠程辦公工作內容很多?;ㄔ谧约翰┛偷臅r間為45小時,相比2019年107小時有所下降。這里統計的是實際上聚焦使用VSCode的時間,應該還算是多的。在此放下統計地址https://wakatime.com/a-look-back-at-2020,方便不知道地址的讀者朋友訪問使用wakatime。

若川的wakatime 2020統計

按照以往的慣例,順帶貼下公司代碼提交記錄和個人github代碼提交記錄。

公司gitlab的代碼提交記錄

github的代碼提交記錄

技術自媒體,慢慢打造個人品牌

微信:再小的個體,也有自己的品牌。我從2016年度總結起,就一直寫了「慢慢打造個人品牌」,真正有所收益時是2020年。

公眾號 8w+ 閱讀

這一年,比較佛系的運營了公眾號「若川視野」,有了工作之外的一些收益。清博大數據和新榜年度報告中都顯示公眾號全年累計8w+閱讀,相比2019年增加2183.73%,雖然可能不是那么準確,但應該也相差不大。

清博大數據統計

年初1月11日時,接了公眾號第一次廣告300元,但后續基本沒有持續更新,比較佛系,接的廣告也比較少。運營公眾號其實是非常耗時耗力的。即使是轉載文章,每天基本都要花上一小時左右選文章發文章。如果是自己寫原創文章,基本每篇文章都需要10小時左右,甚至更多。作為一名互聯網打工人,時間可是稀缺資源啊。

真正醒悟打算不那么佛系,工作日都更新時,已經是11月份了。為什么醒悟了,當時考慮有兩點:1、公眾號對于內容創作者非常有利,同時變現能力非常強,2、相信復利的力量探尋更多可能。在微信公眾號平臺上,聲明過原創的文章,別人在公眾號發布必須取得授權才能轉載發布,而其他平臺隨意轉載抄襲現象很常見。也許在N年后,運營公眾號的副業收入能超過主業。雖然我的公眾號粉絲數還很少,但也能接上一些廣告,有了一些收益,也可以給我的讀者朋友們謀些福利。比如逢年過節發些紅包或者送些書籍,不知不覺在2020年,兩個微信號竟然分別發出了3306.30元、2469.30元,雖然分攤后讀者朋友可能沒收到多少,但我發現發了這么多紅包時是驚訝的,因為相比2019年我發出的紅包是1007.14元,而且我2020年的廣告收入也很少,遠不及一些大佬。在此特別感謝支持我的讀者朋友們,感謝合作投放過廣告的廣告主(主要是開課吧、拉勾教育、珠峰教育)。

說起運營公眾號可以追溯到2013年,「若川視野」前身是我開通的社團的公眾號,那時微信剛推出「微信公眾號」一年左右。那時微信都很少使用,運營公眾號,雖然關注人數少,但公眾號也少,閱讀量相對高。后來就業指導課上,老師要求我們每人寫份簡歷,我寫了一份「新媒體運營」的簡歷,竟然被老師表揚了,被老師流傳至今,發給學弟學妹們參考,學弟學妹們不認識我,可能以為我在做「新媒體運營」相關工作。誰能想到多年以后的我,依舊會走上了新媒體(公眾號)運營這條路。

有時會想是不是我早點運營起公眾號,成果會比現在好呢,也許會的。但反思一下,公眾號運營是要長期持續給粉絲提供價值,才能夠持續長久正向循環。而長期持續提供價值,是需要作者本身有價值可以提供,需要多輸入提升自己才能有價值的多輸出。我同時也清楚地意識到:過早頻繁的接廣告無異于殺雞取卵涸澤而漁,我需要的是多創作出優質的內容,好好運營,先做增量,穩住粉絲增長和閱讀量增長。粉絲對公眾號作者的內容和作者認可后,不會因為公眾號接些廣告而取消關注,畢竟粉絲因公眾號內容受益,也知道做公眾號不容易,也就是人們常說的利他共贏。目前階段接多了廣告,感覺有點愧對讀者朋友的關注。

這一年,清明節假期,開通了第二個微信號,截止到目前有一千多微信好友。兩個微信號累計3500+好友。也有6個微信交流群,共計一千多小伙伴,相比其他公眾號號主來說算很少了。相比2019年,一個微信交流群來說有所進步?,F如今是微信8.0了,支持一萬個微信好友可看朋友圈,也不知道猴年馬月會加滿一萬個微信好友,不過期待這一天早日到來。

運營公眾號以來,也連接到了一些非常優秀的公眾號號主和許多非常優秀的人。偶爾有人加我微信或者在他們的文章中提到,說我的《學習源碼整體架構系列》對TA幫助很大,寫作最開心的莫過于有很多人肯定和支持。甚至有的人說找到了20K的前端開發工作,為了表示感謝,一定要寄來一箱家里種的獼猴桃。而我自己覺得并沒有幫到他們什么忙,不過還是很欣慰能得到大家的肯定和支持。后來想想這樣不對啊,以后都寄來東西給我,而我不能提供相應的幫助,那就不好了。一直以來私聊我答疑解惑時,讀者朋友發給我的紅包都不收,另外也不收讀者朋友寄給我的東西。

知乎 63w+ 閱讀

這一年,知乎「若川」粉絲比去年多了2000+,現在是8377,閱讀量比2019年增長了63w+,2019年時還是4w+閱讀。主要有幾篇回答被知乎推薦了。最高的一篇突破了31w+閱讀量,如下圖所示。雖然我覺得回答的也不是很好。也許是2019年寫了《學習源碼整體架構系列》厚積薄發的表現。

知乎回答閱讀統計

其中高贊的兩篇回答,也同步發表在公眾號,不過知乎上是最新版本。
若川知乎問答:2年前端經驗,做的項目沒什么技術含量,怎么辦?
若川知乎高贊:有哪些必看的 JS 庫?
順便再放下我的《學習源碼整體架構系列》鏈接,koa源碼redux源碼是2020年寫的。2019年下半年寫了6篇,2020年只寫了2篇,2021年會不會繼續寫下去是個謎。
1.學習 jQuery 源碼整體架構,打造屬于自己的 js 類庫
2.學習 underscore 源碼整體架構,打造屬于自己的函數式編程類庫
3.學習 lodash 源碼整體架構,打造屬于自己的函數式編程類庫
4.學習 sentry 源碼整體架構,打造屬于自己的前端異常監控SDK
5.學習 vuex 源碼整體架構,打造屬于自己的狀態管理庫
6.學習 axios 源碼整體架構,打造屬于自己的請求庫
7.學習 koa 源碼的整體架構,淺析koa洋蔥模型原理和co原理
8.學習 redux 源碼整體架構,深入理解 redux 及其中間件原理

其他

這一年,2017年時就開通了免費的知識星球「前端視野 · 若川」的人數也陸陸續續有增長,但更新少了,主要時間和精力放在了公眾號更新上。
這一年,在語雀平臺發布的koa源碼文章,被選為「語雀精選」,比較難得。

koa源碼語雀精選標識

這一年,在掘金平臺上只發了3篇文章,相比2019年多了1000+關注,閱讀量卻多了近10w+,現在累計14w+。

家庭 · 生活

這一年,在家待的時間是近年來最長。由于疫情,從1月20日放假到3月14日返杭,將近兩個月的時間在家,遠程辦公。春節假期很長一段時間都是我媽做飯,我們不用操心。后來我媽上班去了,就是我們自己做飯了。
這一年,國慶和中秋一起,放假在家八天,國慶歸來工作時,總感覺像是做了一場夢,夢回到家里,在家里的時光總是那么短暫。
這一年,給我弟買了一臺新的筆記本電腦。
這一年,清晰的記得父親節那天,和老爸聊天。老爸提起身在體制內堂哥的種種好處,讓我羨慕不已。
這一年,「相親相愛一家人」微信群用得相對多了起來。

學習 · 成長

這一年,沒有參加一場線下技術分享大會,而2019年參加了5場線下技術分享類大會。
這一年,輸入輸出少了,只寫了3篇文章。
這一年,微信讀書記錄只看完了4本書。微信讀書非常不錯,很多書都有,如果喜歡讀書的你還沒用過微信讀書,可以嘗試使用。

體驗 · 突破

這一年,也沒有什么特別的體驗突破。

休閑 · 放松

這一年,很長時間都陷入迷茫焦慮中,有個詞語叫低欲望人群,說的可能就是我這種。周末經??措娨晞』螂娪按虬l時光,麻痹自己...最長的一天(8月16日周日)看了長達7小時??催^《慶余年》、抗疫劇《在一起》、《我在未來等你》、《花木蘭》等。


這一年,沒有去旅行。剛畢業時2016-2017年,那時周末有空都會在杭州一些景點或大學逛逛。如今幾乎不逛了。

總結

站在一年的時間節點上來看全年所度過的光陰。不免又想起孔子在川上的感慨:逝者如斯夫,不舍晝夜。一年很短,列年度計劃時滿懷信心,寫年度總結時卻早已忘卻當初的計劃可能是多數人的狀態。以前寫過一篇《如何制定有價值的目標》,但真正目標管理很好的人是少數。以往年度總結中寫過的一句話「人們往往容易高估自己一年能完成的事,低估自己五年內能完成的事」同樣適用。

寫年度總結的作用在于每年都自我審視和復盤,多年以后能回顧那一年做成了什么、沒做到什么,也許未來的自己會感謝當初努力的自己。通過一定的努力積累,平靜的湖面,也許會激起水花。

我的2020年,總結起來真的是很普通。只寫了3篇文章,佛系運營了公眾號「若川視野」,知乎平臺積累了63w+閱讀量,多年累計起來,全網也算是超過百萬閱讀。
于是把公眾號簡介改為如下:

我是若川,《學習源碼整體架構系列》作者,知乎、掘金等平臺的文章累計超過百萬閱讀。致力于前端開發經驗分享。愿景:幫助5年內前端人開闊視野不斷成長,走在互聯網行業前列。

從2018年起,年度總結文章里基本不列舉年度計劃...這篇文章發給我的群里小伙伴試讀時,有人說寫寫2021年度計劃呀。那就簡單寫下2點:
第一點是:自媒體好好運營,特別是公眾號「若川視野」,創作出更多優質的內容,適當招聘小助理分擔部分工作,嘗試更多可能。
第二點則是:「好好工作,多賺點錢」。


不知不覺寫了5000+字,感謝作為讀者朋友的你看到這里。
最后農歷新年即將到來,預祝各位讀者朋友過一個快樂中國年。在新的一年,遇見更好的自己。

若川
2021年2月4日
查看原文

贊 4 收藏 0 評論 5

蔣鵬飛 發布了文章 · 1月28日

歪門邪道性能優化:魔改三方庫源碼,性能提高幾十倍!

本文會分享一個React性能優化的故事,這也是我在工作中真實遇到的故事,最終我們是通過魔改第三方庫源碼將它性能提高了幾十倍。這個第三方庫也是很有名的,在GitHub上有4.5k star,這就是:react-big-calendar。

這個工作不是我一個人做的,而是我們團隊幾個月前共同完成的,我覺得挺有意思,就將它復盤總結了一下,分享給大家。

在本文中你可以看到:

  1. React常用性能分析工具的使用介紹
  2. 性能問題的定位思路
  3. 常見性能優化的方式和效果:PureComponent, shouldComponentUpdate, Context, 按需渲染等等
  4. 對于第三方庫的問題的解決思路

關于我工作中遇到的故事,我前面其實也分享過兩篇文章了:

  1. 速度提高幾百倍,記一次數據結構在實際工作中的運用
  2. 使用mono-repo實現跨項目組件共享

特別是速度提高幾百倍,記一次數據結構在實際工作中的運用,這篇文章在某平臺單篇閱讀都有三萬多,有些朋友也提出了質疑。覺得我這篇文章里面提到的問題現實中不太可能遇到,里面的性能優化更多是偏理論的,有點杞人憂天。這個觀點我基本是認可的,我在那篇文章正文也提到過可能是個偽需求,但是技術問題本來很多就是理論上的,我們在leetcode上刷題還是純理論呢,理論結合實際才能發揮其真正的價值,即使是杞人憂天,但是性能確實快上了那么一點點,也給大家提供了另一個思路,我覺得也是值得的。

與之相對的,本文提到的問題完全不是杞人憂天了,而是實打實的用戶需求,我們經過用戶調研,發現用戶確實有這么多數據量,需求上不可能再壓縮了,只能技術上優化,這也是逼得我們去改第三方庫源碼的原因。

需求背景

老規矩,為了讓大家快速理解我們遇到的問題,我會簡單講一下我們的需求背景。我還是在那家外企,不久前我們接到一個需求:做一個體育場館管理Web App。這里面有一個核心功能是場館日程的管理,有點類似于大家Outlook里面的Calendar。大家如果用過Outlook,應該對他的Calendar有印象,基本上我們的會議及其他日程安排都可以很方便的放在里面。我們要做的這個也是類似的,體育場館的老板可以用這個日歷來管理他下面場地的預定。

假設你現在是一個羽毛球場的老板,來了個客戶說,嘿,老板,這周六場地有空嗎,我訂一個小時呢!場館每天都很多預定,你也不記得周六有沒有空,所以你打開我們的網站,看了下日歷:

image-20210117111412119

你發現1月15號,也就是星期五有兩個預定,周六還全是空閑的,于是給他說:你運氣真好,周六目前還沒人預定,時段隨便挑!上面這個截圖是react-big-calendar的官方示例,我們也是選定用他來搭建我們自己的應用。

真實場景

上面這個例子只是說明下我們的應用場景,里面預定只有兩個,場地只有一塊。但是我們真實的客戶可比這個大多了,根據我們的調研,我們較大的客戶有數百塊場地,每個場地每天的預定可能有二三十個。上面那個例子我們換個生意比較好的老板,假設這個老板有20塊羽毛球場地,每天客戶都很多,某天還是來了個客戶說,嘿,老板,這周六場地有空嗎,我訂一個小時呢!但是這個老板生意很好,他看到的日歷是這樣的:

image-20210117112848684

本周場館1全滿??!如果老板想要為客戶找到一個有空的場地,他需要連續切換場館1,場館2。。。一直到場館20,手都點酸了。。。為了減少老板手的負擔,我們的產品經理提出一個需求,同時在頁面上顯示10個場館的日歷,好在react-big-calendar本身就是支持這個的,他把這個叫做resources。

性能爆炸

看起來我們要的基本功能react-big-calendar都能提供,前途還是很美好的,直到我們將真實的數據渲染到頁面上。。。我們的預定不僅僅是展示,還需要支持一系列的操作,比如編輯,復制,剪切,粘貼,拖拽等等。當然這一切操作的前提都是選中這個預定,下面這個截圖是我選中某個預定的耗時:

image-20210117114847440

僅僅是一個最簡單的點擊事件,腳本執行耗時6827ms,渲染耗時708ms,總計耗時7.5s左右,這TM!這玩意兒還想賣錢?送給我,我都不想用!

可能有朋友不知道這個性能怎么看,這其實是Chrome自帶的性能工具,基本步驟是:

  1. 打開Chrome調試工具,點到Performance一欄
  2. 點擊左上角的小圓點,開始錄制
  3. 執行你想要的操作,我這里就是點擊一個預定
  4. 等你想要的結果出來,我這里就是點擊的預定顏色加深
  5. 再點擊左上角的小圓點,結束錄制就可以看到了

為了讓大家看得更清楚,我這里錄制了一個操作的動圖,這個圖可以看到,點擊操作的響應花了很長時間,Chrome加載這個性能數據也花了很長時間:

Jan-17-2021 12-51-51

測試數據量

上面僅僅一個點擊耗時就七八秒,是因為我故意用了很大數據量嗎?不是!我的測試數據量是完全按照用戶真實場景計算的:同時顯示10個場館,每個場館每天20個預定,上面使用的是周視圖,也就是可以同時看到7天的數據,那總共顯示的預定就是:

10 * 20 * 7 = 1400,總共1400個預定顯示在頁面上。

為了跟上面這個龜速點擊做個對比,我再放下優化后的動圖,讓大家對后面這個長篇大論實現的效果先有個預期:

Jan-20-2021 16-42-53

定位問題

我們一般印象中,React不至于這么慢啊,如果慢了,大概率是寫代碼的人沒寫好!我們都知道React有個虛擬樹,當一個狀態改變了,我們只需要更新與這個狀態相關的節點就行了,出現這種情況,是不是他干了其他不必要的更新與渲染呢?為了解決這個疑惑,我們安裝了React專用調試工具:React Developer Tools。這是一個Chrome的插件,Chrome插件市場可以下載,安裝成功后,Chrome的調試工具下面會多兩個Tab頁:

image-20210117130740746

Components這個Tab下有個設置,打開這個設置可以看到你每次操作觸發哪些組件更新,我們就是從這里面發現了一點驚喜:

image-20210117130951475

為了看清楚點擊事件觸發哪些更新,我們先減少數據量,只保留一兩個預定,然后打開這個設置看看:

Jan-17-2021 13-21-55

哼,這有點意思。。。我只是點擊一個預定,你把整個日歷的所有組件都給我更新了!那整個日歷有多少組件呢?上面這個圖可以看出10:00 AM10:30 AM之間是一個大格子,其實這個大格子中間還有條分割線,只是顏色較淡,看的不明顯,也就是說每15分鐘就是一個格子。這個15分鐘是可以配置的,你也可以設置為1分鐘,但是那樣格子更多,性能更差!我們是根據需求給用戶提供了15分鐘,30分鐘,1小時等三個選項。當用戶選擇15分鐘的時候,渲染的格子最多,性能最差。

那如果一個格子是15分鐘,總共有多少格子呢?一天是24 * 60 = 1440分鐘,15分鐘一個格子,總共96個格子。我們周視圖最多展示7天,那就是7 * 96 = 672格子,最多可以展示10個場館,就是672 * 10 = 6720個格子,這還沒算日期和時間本身占據的組件,四舍五入一下姑且就算7000個格子吧。

我僅僅是點擊一下預定,你就把作為背景的7000個格子全部給我更新一遍,怪不得性能差!

再仔細看下上面這個動圖,我點擊的是小的那個事件,當我點擊他時,注意大的那個事件也更新了,外面也有個藍框,不是很明顯,但是確實是更新了,在我后面調試打Log的時候也證實了這一點。所以在真實1400條數據下,被更新的還有另外1399個事件,這其實也是不必要的。

我這里提到的事件和前文提到的預定是一個東西,react-big-calendar里面將這個稱為event,也就是事件,對應我們業務的意義就是預定。

為什么會這樣?

這個現象我好像似曾相識,也是我們經常會犯的一個性能上的問題:將一個狀態放到最頂層,然后一層一層往下傳,當下面某個元素更新了這個狀態,會導致根節點更新,從而觸發下面所有子節點的更新。這里說的更新并不一定要重新渲染DOM節點,但是會運行每個子節點的render函數,然后根據render函數運行結果來做diff,看看要不要更新這個DOM節點。React在這一步會幫我們省略不必要的DOM操作,但是render函數的運行卻是必須的,而成千上萬次render函數的運行也會消耗大量性能。

說到這個我想起以前看到過的一個資料,也是講這個問題的,他用了一個一萬行的列表來做例子,原文在這里:high-performance-redux。下面這個例子來源于這篇文章:

function itemsReducer(state = initial_state, action) {
  switch (action.type) {
  case 'MARK':
    return state.map((item) =>
      action.id === item.id ?
        {...item, marked: !item.marked } :
        item
    );
  default:
    return state;
  }
}

class App extends Component {
  render() {
    const { items, markItem } = this.props;
    return (
      <div>
        {items.map(item =>
          <Item key={item.id} id={item.id} marked={item.marked} onClick={markItem} />
        )}
      </div>
    );
  }
};

function mapStateToProps(state) {
  return state;
}

const markItem = (id) => ({type: 'MARK', id});

export default connect(
  mapStateToProps,
  {markItem}
)(App);

上面這段代碼不復雜,就是一個App,接收一個items參數,然后將這個參數全部渲染成Item組件,然后你可以點擊單個Item來改變他的選中狀態,運行效果如下:

Jan-17-2021 15-17-38

這段代碼所有數據都在items里面,這個參數從頂層App傳進去,當點擊Item的時候改變items數據,從而更新整個列表。這個運行結果跟我們上面的Calendar有類似的問題,當單條Item狀態改變的時候,其他沒有涉及的Item也會更新。原因也是一樣的:頂層的參數items改變了。

說實話,類似的寫法我見過很多,即使不是從App傳入,也會從其他大的組件節點傳入,從而引起類似的問題。當數據量少的時候,這個問題不明顯,很多時候都被忽略了,像上面這個圖,即使一萬條數據,因為每個Item都很簡單,所以運行一萬次render你也不會明顯感知出來,在控制臺看也就一百多毫秒。但是我們面臨的Calendar就復雜多了,每個子節點的運算邏輯都更復雜,最終將我們的響應速度拖累到了七八秒上。

優化方案

還是先說這個一萬條的列表,原作者除了提出問題外,也提出了解決方案:頂層App只傳id,Item渲染的數據自己連接redux store獲取。下面這段代碼同樣來自這篇文章:

// index.js
function items(state = initial_state, action) {
  switch (action.type) {
  case 'MARK':
    const item = state[action.id];
    return {
      ...state,
      [action.id]: {...item, marked: !item.marked}
    };
  default:
    return state;
  }
}

function ids(state = initial_ids, action) {
  return state;
}

function itemsReducer(state = {}, action) {
  return {
    // 注意這里,數據多了一個ids
    ids: ids(state.ids, action),
    items: items(state.items, action),
  }
}

const store = createStore(itemsReducer);

export default class NaiveList extends Component {
  render() {
    return (
      <Provider store={store}>
        <App />
      </Provider>
    );
  }
}
// app.js
class App extends Component {
  static rerenderViz = true;
  render() {
    // App組件只使用ids來渲染列表,不關心具體的數據
    const { ids } = this.props;
    return (
      <div>
        {
          ids.map(id => {
            return <Item key={id} id={id} />;
          })
        }
      </div>
    );
  }
};

function mapStateToProps(state) {
  return {ids: state.ids};
}

export default connect(mapStateToProps)(App);
// Item.js
// Item組件自己去連接Redux獲取數據
class Item extends Component {
  constructor() {
    super();
    this.onClick = this.onClick.bind(this);
  }

  onClick() {
    this.props.markItem(this.props.id);
  }

  render() {
    const {id, marked} = this.props.item;
    const bgColor = marked ? '#ECF0F1' : '#fff';
    return (
      <div
        onClick={this.onClick}
      >
        {id}
      </div>
    );
  }
}

function mapStateToProps(_, initialProps) {
  const { id } = initialProps;
  return (state) => {
    const { items } = state;
    return {
      item: items[id],
    };
  }
}

const markItem = (id) => ({type: 'MARK', id});

export default connect(mapStateToProps, {markItem})(Item);

這段代碼的優化主要在這幾個地方:

  1. 將數據從單純的items拆分成了idsitems。
  2. 頂層組件App使用ids來渲染列表,ids里面只有id,所以只要不是增加和刪除,僅僅單條數據的狀態變化,ids并不需要變化,所以App不會更新。
  3. Item組件自己去連接自己需要的數據,當自己關心的數據變化時才更新,其他組件的數據變化并不會觸發更新。

拆解第三方庫源碼

上面通過使用調試工具我看到了一個熟悉的現象,并猜到了他慢的原因,但是目前僅僅是猜測,具體是不是這個原因還要看看他的源碼才能確認。好在我在看他的源碼前先去看了下他的文檔,然后發現了這個:

image-20210117162411789

react-big-calendar接收兩個參數onSelectEventselected,selected表示當前被選中的事件(預定),onSelectEvent可以用來改變selected的值。也就是說當我們選中某個預定的時候,會改變selected的值,由于這個參數是從頂層往下傳的,所以他會引起下面所有子節點的更新,在我們這里就是差不多7000個背景格子 + 1399個其他事件,這樣就導致不需要更新的組件更新了。

頂層selected換成Context?

react-big-calendar在頂層設計selected這樣一個參數是可以理解的,因為使用者可以通過修改這個值來控制選中的事件。這樣選中一個事件就有了兩個途徑:

  1. 用戶通過點擊某個事件來改變selected的值
  2. 開發者可以在外部直接修改selected的值來選中某個事件

有了前面一萬條數據列表優化的經驗,我們知道對于這種問題的處理辦法了:使用selected的組件自己去連接Redux獲取值,而不是從頂部傳入。可惜,react-big-calendar并沒有使用Redux,也沒有使用其他任何狀態管理庫。如果他使用Redux,我們還可以考慮添加一個action來給外部修改selected,可惜他沒有。沒有Redux就玩不轉了嗎?當然不是!React其實自帶一個全局狀態共享的功能,那就是Context。React Context API官方有詳細介紹,我之前的一篇文章也介紹過他的基本使用方法,這里不再講述他的基本用法,我這里想提的是他的另一個特性:使用Context Provider包裹時,如果你傳入的value變了,會運行下面所有節點的render函數,這跟前面提到的普通props是一樣的。但是,如果Provider下面的兒子節點是PureComponent,可以不運行兒子節點的render函數,而直接運行使用這個value的孫子節點。

什么意思呢,下面我將我們面臨的問題簡化來說明下。假設我們只有三層,第一層是頂層容器Calendar,第二層是背景的空白格子(兒子),第三層是真正需要使用selected的事件(孫子):

image-20210119144005794

示例代碼如下:

// SelectContext.js
// 一個簡單的Context
import React from 'react'

const SelectContext = React.createContext()

export default SelectContext;
// Calendar.js
// 使用Context Provider包裹,接收參數selected,渲染背景Background
import SelectContext from './SelectContext';

class Calendar extends Component {
  constructor(...args) {
    super(...args)
    
    this.state = {
      selected: null
    };
    
    this.setSelected = this.setSelected.bind(this);
  }
  
  setSelected(selected) {
    this.setState({ selected })
  }
  
  componentDidMount() {
    const { selected } = this.props;
    
    this.setSelected(selected);
  }
  
  render() {
    const { selected } = this.state;
    const value = {
      selected,
      setSelected: this.setSelected
    }
    
    return (
        <SelectContext.Provider value={value}>
          <Background />
      </SelectContext.Provider>
    )
  }
}
// Background.js
// 繼承自PureComponent,渲染背景格子和事件Event
class Background extends PureComponent {
  render() {
    const { events } = this.props;
    return  (
        <div>
          <div>這里面是7000個背景格子</div>
          下面是渲染1400個事件
          {events.map(event => <Event event={event}/>)}
      </div>
    )
  }
}
// Event.js
// 從Context中取selected來決定自己的渲染樣式
import SelectContext from './SelectContext';

class Event extends Component {
  render() {
    const { selected, setSelected } = this.context;
    const { event } = this.props;
    
    return (
        <div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
      </div>
    )
  }
}

Event.contextType = SelectContext;    // 連接Context

什么是PureComponent?

我們知道如果我們想阻止一個組件的render函數運行,我們可以在shouldComponentUpdate返回false,當新的props相對于老的props來說沒有變化時,其實就不需要運行render,shouldComponentUpdate就可以這樣寫:

shouldComponentUpdate(nextProps) {
    const fields = Object.keys(this.props)
    const fieldsLength = fields.length
    let flag = false

    for (let i = 0; i < fieldsLength; i = i + 1) {
      const field = fields[i]
      if (
        this.props[field] !== nextProps[field]
      ) {
        flag = true
        break
      }
    }

    return flag
  }

這段代碼就是將新的nextProps與老的props一一進行對比,如果一樣就返回false,不需要運行render。而PureComponent其實就是React官方幫我們實現了這樣一個shouldComponentUpdate。所以我們上面的Background組件繼承自PureComponent,就自帶了這么一個優化。如果Background本身的參數沒有變化,他就不會更新,而Event因為自己連接了SelectContext,所以當SelectContext的值變化的時候,Event會更新。這就實現了我前面說的如果Provider下面的兒子節點是PureComponent,可以不運行兒子節點的render函數,而直接運行使用這個value的孫子節點。

PureComponent不起作用

理想是美好的,現實是骨感的。。。理論上來說,如果我將中間兒子這層改成了PureComponent,背景上7000個格子就不應該更新了,性能應該大幅提高才對。但是我測試后發現并沒有什么用,這7000個格子還是更新了,什么鬼?其實這是PureComponent本身的一個問題:只進行淺比較。注意this.props[field] !== nextProps[field],如果this.props[field]是個引用對象呢,比如對象,數組之類的?因為他是淺比較,所以即使前后屬性內容沒變,但是引用地址變了,這兩個就不一樣了,就會導致組件的更新!

而在react-big-calendar里面大量存在這種計算后返回新的對象的操作,比如他在頂層Calendar里面有這種操作:

image-20210119151326161

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L790

這行代碼的意思是每次props改變都去重新計算狀態state,而他的計算代碼是這樣的:

image-20210119151747973

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/Calendar.js#L794

注意他的返回值是一個新的對象,而且這個對象里面的屬性,比如localizer的計算方法mergeWithDefaults也是這樣,每次都返回新的對象:

image-20210119151956459

代碼地址:https://github.com/jquense/react-big-calendar/blob/master/src/localizer.js#L39

這樣會導致中間兒子節點每次接受到的props雖然內容是一樣的,但是因為是一個新對象,即使使用了PureComponent,其運行結果也是需要更新。這種操作在他的源碼中大量存在,其實從功能角度來說,這樣寫是可以理解的,因為我有時候也會這么干。。。有時候某個屬性更新了,不太確定要不要更新下面的組件,干脆直接返回一個新對象觸發更新,省事是省事了,但是面對我們這種近萬個組件的時候性能就崩了。。。

歪門邪道shouldComponentUpdate

如果只有一兩個屬性是這樣返回新對象,我還可以考慮給他重構下,但是調試了一下發現有大量的屬性都是這樣,咱也不是他作者,也不知道會不會改壞功能,沒敢亂動。但是不動性能也繃不住啊,想來想去,還是在兒子的shouldComponentUpdate上動點手腳吧。簡單的this.props[field] !== nextProps[field]判斷肯定是不行的,因為引用地址變啦,但是他內容其實是沒變,那我們就判斷他的內容吧。兩個對象的深度比較需要使用遞歸,也可以參考React diff算法來進行性能優化,但是無論你怎么優化這個算法,性能最差的時候都是兩個對象一樣的時候,因為他們是一樣的,你需要遍歷到最深處才能肯定他們是一樣的,如果對象很深,這種遞歸算法不見得會比運行一遍render快,而我們面臨的大多數情況都是這種性能最差的情況。所以遞歸對比不太靠譜,其實如果你對這些數據心里有數,沒有循環引用什么的,你可以考慮直接將兩個對象轉化為字符串來進行對比,也就是

JSON.stringify(this.props[field]) !== JSON.stringify(nextProps[field])

注意,這種方式只適用于你對props數據了解,沒有循環引用,沒有變化的Symbol,函數之類的屬性,因為JSON.stringify執行時會丟掉Symbol和函數,所以我說他是歪門邪道性能優化。

將這個轉化為字符串比較的shouldComponentUpdate加到背景格子的組件上,性能得到了明顯增強,點擊相應速度從7.5秒下降到了5.3秒左右。

image-20210119160608456

按需渲染

上面我們用shouldComponentUpdate阻止了7000個背景格子的更新,響應時間下降了兩秒多,但是還是需要5秒多時間,這也很難接受,還需要進一步優化。按照我們之前說的如果還能阻止另外1399個事件的更新那就更好了,但是經過對他數據結構的分析,我們發現他的數據結構跟我們前面舉的列表例子還不一樣。我們列表的例子所有數據都在items里面,是否選中是item的一個屬性,而react-big-calendar的數據結構里面eventselectedEvent是兩個不同的屬性,每個事件通過判斷自己的event是否等于selectedEvent來判斷自己是否被選中。這造成的結果就是每次我們選中一個事件,selectedEvent的值都會變化,每個事件的屬性都會變化,也就是會更新,運行render函數。如果不改這種數據結構,是阻止不了另外1399個事件更新的。但是改這個數據結構改動太大,對于一個第三方庫,我們又不想動這么多,怎么辦呢?

這條路走不通了,我們完全可以換一個思路,背景7000個格子,再加上1400個事件,用戶屏幕有那么大嗎,看得完嗎?肯定是看不完的,既然看不完,那我們只渲染他能看到部分不就可以了!按照這個思路,我們找到了一個庫:react-visibility-sensor。這個庫使用方法也很簡單:

function MyComponent (props) {
  return (
    <VisibilitySensor>
      {({isVisible}) =>
        <div>I am {isVisible ? 'visible' : 'invisible'}</div>
      }
    </VisibilitySensor>
  );
}

結合我們前面說的,我們可以將VisibilitySensor套在Background上面:

class Background extends PureComponent {
  render() {
    return (
      <VisibilitySensor>
        {({isVisible}) =>
          <Event isVisible={isVisible}/>
        }
      </VisibilitySensor>
    )
  }
}

然后Event組件如果發現自己處于不可見狀態,就不用渲染了,只有當自己可見時才渲染:

class Event extends Component {
  render() {
    const { selected } = this.context;
    const { isVisible, event } = this.props;
    
    return (
      { isVisible ? (
       <div className={ selected === event ? 'class1' : 'class2'}>
          復雜內容
       </div>
      ) : null}
    )
  }
}

Event.contextType = SelectContext;

按照這個思路我們又改了一下,發現性能又提升了,整體時間下降到了大概4.1秒:

image-20210120140421092

仔細看上圖,我們發現渲染事件Rendering時間從1秒左右下降到了43毫秒,快了二十幾倍,這得益于渲染內容的減少,但是Scripting時間,也就是腳本執行時間仍然高達4.1秒,還需要進一步優化。

砍掉mousedown事件

渲染這塊已經沒有太多辦法可以用了,只能看看Scripting了,我們發現性能圖上鼠標事件有點刺眼:

image-20210119170345316

一次點擊同時觸發了三個點擊事件:mousedown,mouseup,click。如果我們能干掉mousedown,mouseup是不是時間又可以省一半,先去看看他注冊這兩個事件時干什么的吧??梢灾苯釉诖a里面全局搜mousedown,最終發現都是在Selection.js,通過對這個類代碼的閱讀,發現他是個典型的觀察者模式,然后再搜new Selection找到使用的地方,發現mousedown,mouseup主要是用來實現事件的拖拽功能的,mousedown標記拖拽開始,mouseup標記拖拽結束。如果我把它去掉,拖拽功能就沒有了。經過跟產品經理溝通,我們后面是需要拖拽的,所以這個不能刪。

事情進行到這里,我也沒有更多辦法了,但是響應時間還是有4秒,真是讓人頭大

image-20210120144109109

反正沒啥好辦法了,我就隨便點著玩,突然,我發現mousedown的調用棧好像有點問題:

image-20210120144433528

這個調用棧我用數字分成了三塊:

  1. 這里面有很多熟悉的函數名啊,像啥performUnitOfWork,beginWork,這不都是我在React Fiber這篇文章中提過的嗎?所以這些是React自己內部的函數調用
  2. render函數,這是某個組件的渲染函數
  3. 這個render里面又調用了renderEvents函數,看起來是用來渲染事件列表的,主要的時間都耗在這里了

mousedown監聽本身我是干不掉了,但是里面的執行是不是可以優化呢?renderEvents已經是庫自己寫的代碼了,所以可以直接全局搜,看看在哪里執行的。最終發現是在TimeGrid.jsrender函數被執行了,其實這個是不需要執行的,我們直接把前面歪門邪道的shouldComponentUpdate復制過來就可以阻止他的執行。然后再看下性能數據呢:

image-20210120145945555

我們發現Scripting下降到了3.2秒左右,比之前減少約800毫秒,而mousedown的時間也從之前的幾百毫秒下降到了50毫秒,在圖上幾乎都看不到了,mouseup事件也不怎么看得到了,又算進了一步吧~

忍痛閹割功能

到目前為止,我們的性能優化都沒有閹割功能,響應速度從7.5秒下降到了3秒多一點,優化差不多一倍。但是,目前這速度還是要三秒多,別說作為一個工程師了,作為一個用戶我都忍不了。咋辦呢?我們是真的有點黔驢技窮了。。。

看看上面那個性能圖,主要消耗時間的有兩個,一個是click事件,還有個timer。timer到現在我還不知道他哪里來的,但是click事件我們是知道的,就是用戶點擊某個事件后,更改SelectContextselected屬性,然后selected屬性從頂層節點傳入觸發下面組件的更新,中間兒子節點通過shouldComponentUpdate跳過更新,孫子節點直接連接SelectContext獲取selected屬性更新自己的狀態。這個流程是我們前面優化過的,但是,等等,這個貌似還有點問題。

在我們的場景中,中間兒子節點其實包含了高達7000個背景格子,雖然我們通過shouldComponentUpdate跳過了render的執行,但是7000個shouldComponentUpdate本省執行也是需要時間的??!有沒有辦法連shouldComponentUpdate的執行也跳過呢?這貌似是個新的思路,但是經過我們的討論,發現沒辦法在保持功能的情況下做到,但是可以適度閹割一個功能就可以做到,那閹割的功能是哪個呢?那就是暴露給外部的受控selected屬性!

前面我們提到過選中一個事件有兩個途徑:

  1. 用戶通過點擊某個事件來改變selected的值
  2. 開發者可以在外部直接修改selected的值來選中某個事件

之所以selected要放在頂層組件上就是為了實現第二個功能,讓外部開發者可以通過這個受控的selected屬性來改變選中的事件。但是經過我們評估,外部修改selected這個并不是我們的需求,我們的需求都是用戶點擊來選中,也就是說外部修改selected這個功能我們可以不要。

如果不要這個功能那就有得玩了,selected完全不用放在頂層了,只需要放在事件外層的容器上就行,這樣,改變selected值只會觸發事件的更新,啥背景格子的更新壓根就不會觸發,那怎么改呢?在我們前面的Calendar -- Background -- Event模型上再加一層EventContainer,變成Calendar -- Background -- EventContainer -- Event。SelectContext.Provider也不用包裹Calendar了,直接包裹EventContainer就行。代碼大概是這個樣子:

// Calendar.js
// Calendar簡單了,不用接受selected參數,也不用SelectContext.Provider包裹了
class Calendar extends Component {
  render() {
    return (
      <Background />
    )
  }
}
// Background.js
// Background要不要使用shouldComponentUpdate阻止更新可以看看還有沒有其他參數變化,因為selected已經從頂層拿掉了
// 改變selected本來就不會觸發Background更新
// Background不再渲染單個事件,而是渲染EventContainer
class Background extends PureComponent {
  render() {
    const { events } = this.props;
    return  (
        <div>
          <div>這里面是7000個背景格子</div>
          下面是渲染1400個事件
          <EventContainer events={events}/>
      </div>
    )
  }
}
// EventContainer.js
// EventContainer需要SelectContext.Provider包裹
// 代碼類似之前的Calendar
import SelectContext from './SelectContext';

class EventContainer extends Component {
  constructor(...args) {
    super(...args)
    
    this.state = {
      selected: null
    };
    
    this.setSelected = this.setSelected.bind(this);
  }
  
  setSelected(selected) {
    this.setState({ selected })
  }
  
  render() {
    const { selected } = this.state;
    const { events } = this.props;
    const value = {
      selected,
      setSelected: this.setSelected
    }
    
    return (
        <SelectContext.Provider value={value}>
          {events.map(event => <Event event={event}/>)}
      </SelectContext.Provider>
    )
  }
}
// Event.js
// Event跟之前是一樣的,從Context中取selected來決定自己的渲染樣式
import SelectContext from './SelectContext';

class Event extends Component {
  render() {
    const { selected, setSelected } = this.context;
    const { event } = this.props;
    
    return (
        <div className={ selected === event ? 'class1' : 'class2'} onClick={() => setSelected(event)}>
      </div>
    )
  }
}

Event.contextType = SelectContext;    // 連接Context

這種結構最大的變化就是當selected變化的時候,更新的節點是EventContainer,而不是頂層Calendar,這樣就不會觸發Calendar下其他節點的更新。缺點就是Calendar無法從外部接收selected了。

需要注意一點是,如果像我們這樣EventContainer下面直接渲染Event列表,selected不用Context也可以,可以直接作為EventContainerstate。但是如果EventContainerEvent中間還有層級,需要穿透傳遞,仍然需要Context,中間層級和以前的類似,使用shouldComponentUpdate阻止更新。

還有一點,因為selected不在頂層了,所以selected更新也不會觸發中間Background更新了,所以Background上的shouldComponentUpdate也可以刪掉了。

我們這樣優化后,性能又提升了:

image-20210120161336248

現在Scripting時間直接從3.2秒降到了800毫秒,其中click事件只有163毫秒,現在從我使用來看,卡頓已經不明顯了,直接錄個動圖來對比下吧:

Jan-20-2021 16-42-53

上面這個動圖已經基本看不出卡頓了,但是我們性能圖上為啥還有800毫秒呢,而且有一個很長的Timer Fired。經過我們的仔細排查,發現這其實是個烏龍,Timer Fired在我一開始錄制性能就出現了,那時候我還在切換頁面,還沒來得及點擊呢,如果我們點進去會發現他其實是按需渲染引入的react-visibility-sensor的一個檢查元素可見性的定時任務,并不是我們點擊事件的響應時間。把這塊去掉,我們點擊事件的響應時間其實不到200毫秒。

從7秒多優化到不到200毫秒,三十多倍的性能優化,終于可以交差了,哈哈??

總結

本文分享的是我工作中實際遇到的一個案例,實現的效果是將7秒左右的響應時間優化到了不到200毫秒,優化了三十幾倍,優化的代價是犧牲了一個不常用的功能。

本來想著要是優化好了可以給這個庫提個PR,造福大家的。但是優化方案確實有點歪門邪道:

  1. 使用了JSON.stringify來進行shouldComponentUpdate的對比優化,對于函數,Symbol屬性的改變沒法監聽到,不適合開放使用,只能在數據自己可控的情況下小規模使用。
  2. 犧牲了一個暴露給外部的受控屬性selected,破壞了功能。

基于這兩點,PR我們就沒提了,而是將修改后的代碼放到了自己的私有NPM倉庫。

下面再來總結下本文面臨的問題和優化思路:

遇到的問題

我們需求是要做一個體育場館的管理日歷,所以我們使用了react-big-calendar這個庫。我們需求的數據量是渲染7000個背景格子,然后在這個背景格子上渲染1400個事件。這近萬個組件渲染后,我們發現僅僅一次點擊就需要7秒多,完全不能用。經過細致排查,我們發現慢的原因是點擊事件的時候會改變一個屬性selected。這個屬性是從頂層傳下來的,改變后會導致所有組件更新,也就是所有組件都會運行render函數。

第一步優化

為了阻止不必要的render運行,我們引入了Context,將selected放到Context上進行透傳。中間層級因為不需要使用selected屬性,所以可以使用shouldComponentUpdate來阻止render的運行,底層需要使用selected的組件自行連接Context獲取。

第一步優化的效果

響應時間從7秒多下降到5秒多。

第一步優化的問題

底層事件仍然有1400個,獲取selected屬性后,1400個組件更新仍然要花大量的時間。

第二步優化

為了減少點擊后更新的事件數量,我們為事件引入按需渲染,只渲染用戶可見的事件組件。同時我們還對mousedownmouseup進行了優化,也是使用shouldComponentUpdate阻止了不必要的更新。

第二步優化效果

響應時間從5秒多下降到3秒多。

第二步優化的問題

響應時間仍然有三秒多,經過分析發現,背景7000個格子雖然使用shouldComponentUpdate阻止了render函數的運行,但是shouldComponentUpdate本身運行7000次也要費很長時間。

第三步優化

為了讓7000背景格子連shouldComponentUpdate都不運行,我們忍痛閹割了頂層受控的selected屬性,直接將它放到了事件的容器上,它的更新再也不會觸發背景格子的更新了,也就是連shouldComponentUpdate都不運行了。

第三步優化效果

響應時間從3秒多下降到不到200毫秒。

第三步優化的問題

功能被閹割了,其他完美!

參考資料:

react-big-calendar倉庫

high-performance-redux

文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

查看原文

贊 33 收藏 18 評論 12

蔣鵬飛 贊了文章 · 1月22日

這次,十分鐘把宏任務和微任務講清楚

為什么寫這個文章

  • 這是一道大廠、小廠面試官都喜歡問的題目
  • 很多面試官和面試者也不知道什么是標準答案
  • 網上各種文章層次不齊..誤導過不少人,包括我
  • 覺得還是今天花十分鐘講清楚他吧

正式開始

  • 先上代碼
    function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 輸出結果:
1-2
1-3
1-1
2-1
3-1

開始分析

  • 面試官特別喜歡問:你講講什么是微任務和宏任務
大部分面試官其實自己也不懂什么是微任務和宏任務,不信下次你們反問一下

所謂微任務和宏任務

  • 宏任務:常見的定時器,用戶交互事件等等.(宏任務就是特定的這些個任務,沒什么特殊含義)
  • 微任務:Promise相關任務,MutationObserver等(一樣,只是一種稱呼而已?。?!

到底先執行微任務還是宏任務

  • 先有雞還是先有蛋? 到底是先有宏任務還是微任務啊?

第一個原則

  • 萬物皆從全局上下文準備退出,全局的同步代碼運行結束的這個時機開始
  • 例如我們剛才這段代碼:
   function app() {
      setTimeout(() => {
        console.log("1-1");
        Promise.resolve().then(() => {
          console.log("2-1");
        });
      });
      console.log("1-2");
      Promise.resolve().then(() => {
        console.log("1-3");
        setTimeout(() => {
          console.log("3-1");
        });
      });
    }
    app();
  • 當執行完了console.log("1-2");的時候,意味著全局的上下文馬上要退出了,因為此時全局的同步代碼都執行完了,剩下的都是異步代碼

第二個原則

  • 同一層級下(不理解層級,可以先不管,后面會講),微任務永遠比宏任務先執行
  • 即Promise.then比setTimeout先執行
  • 所以先打印1-3,再打印1-1

第三個原則

  • 每個宏任務,都單獨關聯了一個微任務隊列
  • 我用剛買的黑板畫了一張圖,大家就知道什么是層級了

  • 每個層級的宏任務,都對應了他們的微任務隊列,微任務隊列遵循先進先出的原則,當全局同步代碼執行完畢后,就開始執行第一層的任務。同層級的微任務永遠先于宏任務執行,并且會在當前層級宏任務結束前全部執行完畢

怎么分辨層級?

  • 屬于同一個維度的代碼,例如下面的func1和func2就屬于同層級任務
setTimeout(func1)...
Promise.resolve().then(func2)...
  • 下面這種fn1和fn2就不屬于同一個層級的,因為fn2屬于內部這個setTimeout的微任務隊列,而fn1屬于外部setTimeout的微任務隊列
setTimeout(()=>{
Promise.resolve().then(fn1)
setTimeout(()=>{
Promise.resolve().then(fn2)  
})})
劃重點:每個宏任務對應一個單獨的微任務隊列

遇到面試題

  • 就按照我的套路,從全局上下文退出前(全局的同步代碼執行完畢后),開始收集當前層級的微任務和宏任務,然后先清空微任務隊列,再執行宏任務.如果這期間遇到宏任務/微任務,就像我這樣畫個圖,把他們塞進對應的層級里即可

寫在最后

  • 簡單的1000字,相信能徹底解決你的微任務和宏任務疑惑
  • 如果你想理解得更深,記得關注下公眾號,后續會寫一些更深入的東西,真正的“深入淺出”
查看原文

贊 22 收藏 12 評論 2

蔣鵬飛 發布了文章 · 1月19日

前端也能學算法:由淺入深講解貪心算法

貪心算法是一種很常見的算法思想,而且很好理解,因為它符合人們一般的思維習慣。下面我們由淺入深的來講講貪心算法。

找零問題

我們先來看一個比較簡單的問題:

假設你是一個商店老板,你需要給顧客找零n元錢,你手上有的錢的面值為:100元,50元,20元,5元,1元。請問如何找零使得所需要的錢幣數量最少?

例子:你需要找零126元,則所需錢幣數量最少的方案為100元1找,20元1張,5元1張,1元1張。

這個問題在生活中很常見,買東西的時候經常會遇到,那我們一般是怎么思考的呢?假設我們需要找零126元,我們先看看能找的最大面值是多少,我們發現126比100大,那肯定可以找一張100塊,然后剩下26元,再看26能匹配的最大面值是多少,發現是20,那找一張20的,還剩6塊,同樣的思路,找一張5塊的和1塊的。這其實就是貪心算法的思想,每次都很貪心的去找最大的匹配那個值,然后再找次大的。這個算法代碼也很好寫:

const allMoney = [100, 50, 20, 5, 1];  // 表示我們手上有的面值
function changeMoney(n, allMoney) {
  const length = allMoney.length;
  const result = [];    // 存儲結果的數組,每項表示對應面值的張數
  for(let i = 0; i < length; i++) {
    if(n >= allMoney[i]) {
      // 如果需要找的錢比面值大,那就可以找,除一下看看能找幾張
      result[i] = parseInt(n / allMoney[i]);
      n = n - result[i] * allMoney[i];   // 更新剩下需要找的錢
    } else {
      // 否則不能找
      result[i] = 0;
    }
  }
  
  return result;
}

const result = changeMoney(126, allMoney);
console.log(result);   // [1, 0, 1, 1, 1]

貪心算法

上面的找零問題就是貪心算法,每次都去貪最大面值的,發現貪不了了,再去貪次大的。從概念上講,貪心算法是:

image-20200220105715893

從上面的定義可以看出,并不是所有問題都可以用貪心算法來求解的,因為它每次拿到的只是局部最優解,局部最優解組合起來并不一定是全局最優解。下面我們來看一個這樣的例子:

背包問題

背包問題也是一個很經典的算法問題,題目如下:

有一個小偷,他進到了一個店里要偷東西,店里有很多東西,每個東西的價值是v,每個東西的重量是w。但是小偷只有一個背包,他背包總共能承受的重量是W。請問怎么拿東西能讓他拿到的價值最大?

其實背包問題細分下來又可以分成兩個問題:0-1背包和分數背包。

0-1背包:指的是對于某個商品來說,你要么不拿,要么全拿走,不能只拿一半或者只拿三分之二??梢詫⑸唐防斫獬山鸫u,你要么整塊拿走,要么不拿,不能拿半塊。

分數背包:分數背包就是跟0-1背包相反的,你可以只拿一部分,可以拿一半,也可以拿三分之二??梢詫⑸唐防斫獬山鹕?,可以只拿一部分。

下面來看個例子:

image-20200220110835213

這個問題用我們平時的思維也很好想,要拿到總價值最大,那我們就貪唄,就拿最貴的,即價值除以重量的數最大的。但是每次都拿最貴的,是不是最后總價值最大呢?我們先假設上面的例子是0-1背包,最貴的是v1,然后是v2,v3。我們先拿v1, 背包還剩40,拿到總價值是60,然后拿v2,背包還剩20,拿到總價值是160。然后就拿不下了,因為v3的重量是30,我們背包只剩20了,裝不下了。但是這個顯然不是全局最優解,因為我們明顯可以看出,如果我們拿v2,v3,背包剛好裝滿,總價值是220,這才是最優解。所以0-1背包問題不能用貪心算法。

但是分數背包可以用貪心,因為我們總是可以拿最貴的。我們先拿了v1, v2,發現v3裝不下了,那就不裝完了嘛,裝三分之二就行了。下面我們用貪心來實現一個分數背包:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
  {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每個商品加個id用于標識

function backpack(W, products) {
  const sortedProducts = products.sort((product1, product2) => {
    const price1 = product1.v / product1.w;
    const price2 = product2.v / product2.w;
    if(price1 > price2) {
      return -1;
    } else if(price1 < price2) {
      return 1;
    }
    
    return 0;
  });  // 先對商品按照價值從大到小排序
  
  const result = []; // 新建數組接收結果
  let allValue = 0;  // 拿到的總價值
  const length = sortedProducts.length;
  
  for(let i = 0; i < length; i++) {
    const sortedProduct = sortedProducts[i];
    if(W >= sortedProduct.w) {
      // 整個拿完
      result.push({
        id: sortedProduct.id,
        take: 1,     // 拿的數量
      });
      W = W - sortedProduct.w;
      allValue = allValue + sortedProduct.v;
    } else if(W > 0) {
      // 只能拿一部分
      result.push({
        id: sortedProduct.id,
        take: W / sortedProduct.w,     
      });
      allValue = allValue + sortedProduct.v * (W / sortedProduct.w);
      W = 0; // 裝滿了
    } else {
      // 不能拿了
      result.push({
        id: sortedProduct.id,
        take: 0,     
      });
    }
  }
  
  return {result: result, allValue: allValue};
}

// 測試一下
const result = backpack(50, products);
console.log(result);

運行結果:

image-20200220113537290

0-1背包

前面講過0-1背包不能用貪心求解,我們這里還是講講他怎么來求解吧。要解這個問題需要用到動態規劃的思想,關于動態規劃的思想,可以看看我這篇文章,如果你只想看看貪心算法,可以跳過這一部分。假設我們背包放了n個商品,W是我們背包的總容量,我們這時擁有的總價值是$D(n, W)$。我們考慮最后一步,

假如我們不放最后一個商品,則總價值為$D(n-1, W)$

假設我們放了最后一個商品,則總價值為最后一個商品加上前面已經放了的價值,表示為$v_n + D(n-1, W-w_n)$,這時候需要滿足的條件是$ W >= w_n$,即最后一個要放得下。

我們要求的最大解其實就是上述兩個方案的最大值,表示如下:

$$ D(n, W) = max(D(n-1, W), v_n + D(n-1, W-w_n)) $$

遞歸解法

有了遞推公式,我們就可以用遞歸解法了:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每個商品加個id用于標識

function backpack01(n, W, products) {
  if(n < 0 || W <= 0) {
    return 0;
  }
  
  const noLast = backpack01(n-1, W, products);  // 不放最后一個
  
  let getLast = 0;
  if(W >= products[n].w){  // 如果最后一個放得下
    getLast = products[n].v + backpack01(n-1, W-products[n].w, products);
  }
  
  const result = Math.max(noLast, getLast);
  
  return result;
}

// 測試一下
const result = backpack01(products.length-1, 50, products);
console.log(result);   // 220

動態規劃

遞歸的復雜度很高,我們用動態規劃重寫一下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每個商品加個id用于標識

function backpack01(W, products) {
  const d = [];      // 初始化一個數組放計算中間值,其實為二維數組,后面填充里面的數組
  const length = products.length;
  
  // i表示行,為商品個數,數字為 0 -- (length - 1)
  // j表示列,為背包容量,數字為 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量為0
        d[i][j] = 0;
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一個商品
          d[i][j] = products[i].v;
        } else {
          d[i][j] = 0;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
        }
      }
    }
  }
  
  console.log(d);
  return d[length-1][W];
}

// 測試一下
const result = backpack01(50, products);
console.log(result);   // 220

回溯最優解

為了能夠輸出最優解,我們需要將每個最后放入的商品記錄下來,然后從最后往前回溯,將前面的代碼改造如下:

const products = [
  {id:1, v: 60, w: 10}, 
  {id:2, v: 100, w: 20}, 
    {id:3, v: 120, w: 30}
];    // 新建一個數組表示商品列表,每個商品加個id用于標識

function backpack01(W, products) {
  const d = [];      // 初始化一個數組放計算中間值,其實為二維數組,后面填充里面的數組
  const res = [];    // 記錄每次放入的最后一個商品, 同樣為二維數組
  const length = products.length;
  
  // i表示行,為商品個數,數字為 0 -- (length - 1)
  // j表示列,為背包容量,數字為 0 -- W
  for(let i = 0; i < length; i++){
    d.push([]);
    res.push([]);
    for(let j = 0; j <= W; j++) {
      if(j === 0) {
        // 背包容量為0
        d[i][j] = 0;
        res[i][j] = null;  
      } else if(i === 0) {
        if(j >= products[i].w) {
          // 可以放下第一個商品
          d[i][j] = products[i].v;
          res[i][j] = products[i];
        } else {
          d[i][j] = 0;
          res[i][j] = null;
        }
      } else {
        const noLast = d[i-1][j];
        
        let getLast = 0;
        if(j >= products[i].w) {
          getLast = products[i].v + d[i-1][j - products[i].w];
        }
        
        if(noLast > getLast) {
          d[i][j] = noLast;
        } else {
          d[i][j] = getLast;
          res[i][j] = products[i];   // 記錄最后一個商品
        }
      }
    }
  }
  
  // 回溯res, 得到最優解
  let tempW = W;
  let tempI = length - 1;
  const bestSol = [];
  while (tempW > 0 && tempI >= 0) {
    const last = res[tempI][tempW];
    bestSol.push(last);
    tempW = tempW - last.w;
    tempI = tempI - 1;
  }
  
  console.log(d);
  console.log(bestSol);
  return {
    totalValue: d[length-1][W],
    solution: bestSol
  }
}

// 測試一下
const result = backpack01(50, products);
console.log(result);   // 220

上面代碼的輸出:

image-20200220144941561

數字拼接問題

再來看一個貪心算法的問題,加深下理解,這個問題如下:

image-20200220153438242

這個問題看起來也不難,我們有時候也會遇到類似的問題,我們可以很直觀的想到一個解法:看哪個數字的第一個數字大,把他排前面,比如32和94,把第一位是9的94放前面,得到9432,肯定比32放前面的3294大。這其實就是按照字符串大小來排序嘛,字符大的排前面,但是這種解法正確嗎?我們再來看兩個數字,假如我們有728和7286,按照字符序,7286排前面,得到7286728,但是這個值沒有728放前面的7287286大。說明單純的字符序是搞不定這個的,對于兩個數字a,b,如果他們的長度一樣,那按照字符序就沒問題,如果他們長度不一樣,這個解法就不一定對了,那怎么辦呢?其實也簡單,我們看看a+b和b+a拼成的數字,哪個大就行了。

假設
a = 728
b = 7286
字符串: a + b = "7287286"
字符串: b + a = "7286728"
比較下這兩個字符串, a + b比較大,a放前面就行了, 反之放到后面

上述算法就是一個貪心,這里貪的是什么的?貪的是a + b的值,要大的那個。在實現的時候,可以自己寫個冒泡,也可以直接用數組的sort方法:

const nums = [32, 94, 128, 1286, 6, 71];

function getBigNum(nums) {
  nums.sort((a, b) => {
    const ab = `${a}$`;
    const ba = `$${a}`;
    
    if(ab > ba) {
      return -1;   // ab大,a放前面
    } else if (ab < ba) {
      return 1;  
    }
    
    return 0;
  });
  
  return nums;
}

const res = getBigNum(nums);
console.log(res);    // [94, 71, 6, 32, 1286, 128]

活動選擇問題

活動選擇問題稍微難一點,也可以用貪心,但是需要貪的東西沒前面的題目那么直觀,我們先來看看題目:

image-20200220155950342

這個問題應該這么思考:為了能盡量多的安排活動,我們在安排一個活動時,應該盡量給后面的活動多留時間,這樣后面有機會可以安排更多的活動。換句話說就是,應該把結束時間最早的活動安排在第一個,再剩下的時間里面繼續安排結束時間早的活動。這里的貪心其實貪的就是結束時間早的,這個結論其實可以用數學來證明的:

image-20200220161538654

下面來實現下代碼:

const activities = [
  {start: 1, end: 4},
  {start: 3, end: 5},
  {start: 0, end: 6},
  {start: 5, end: 7},
  {start: 3, end: 9},
  {start: 5, end: 9},
  {start: 6, end: 10},
  {start: 8, end: 11},
  {start: 8, end: 12},
  {start: 2, end: 14},
  {start: 12, end: 16},
];

function chooseActivity(activities) {
  // 先按照結束時間從小到大排序
  activities.sort((act1, act2) => {
    if(act1.end < act2.end) {
      return -1;
    } else if(act1.end > act2.end) {
      return 1;
    }
    
    return 0;
  });
  
  const res = [];  // 接收結果的數組
  let lastEnd = 0; // 記錄最后一個活動的結束時間
  
  for(let i = 0; i < activities.length; i++){
    const act = activities[i];
    if(act.start >= lastEnd) {
      res.push(act);
      lastEnd = act.end
    }
  }
  
  return res;
}

// 測試一下
const result = chooseActivity(activities);
console.log(result);

上面代碼的運行結果如下:

image-20200220163750591

總結

貪心算法的重點就在一個貪字,要找到貪的對象,然后不斷的貪,最后把目標貪完,輸出最優解。要注意的是,每次貪的時候其實拿到的都只是局部最優解,局部最優解不一定組成全局最優解,比如0-1背包,對于這種問題是不能用貪心的,要用其他方法求解。

文章的最后,感謝你花費寶貴的時間閱讀本文,如果本文給了你一點點幫助或者啟發,請不要吝嗇你的贊和GitHub小星星,你的支持是作者持續創作的動力。

歡迎關注我的公眾號進擊的大前端第一時間獲取高質量原創~

“前端進階知識”系列文章源碼地址: https://github.com/dennis-jiang/Front-End-Knowledges

1270_300二維碼_2.png

查看原文

贊 26 收藏 17 評論 6

蔣鵬飛 收藏了文章 · 1月18日

可視化拖拽組件庫一些技術要點原理分析(二)

本文是對《可視化拖拽組件庫一些技術要點原理分析》的補充。上一篇文章主要講解了以下幾個功能點:

  1. 編輯器
  2. 自定義組件
  3. 拖拽
  4. 刪除組件、調整圖層層級
  5. 放大縮小
  6. 撤消、重做
  7. 組件屬性設置
  8. 吸附
  9. 預覽、保存代碼
  10. 綁定事件
  11. 綁定動畫
  12. 導入 PSD
  13. 手機模式

現在這篇文章會在此基礎上再補充 4 個功能點,分別是:

  • 拖拽旋轉
  • 復制粘貼剪切
  • 數據交互
  • 發布

和上篇文章一樣,我已經將新功能的代碼更新到了 github:

友善提醒:建議結合源碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。

14. 拖拽旋轉

在寫上一篇文章時,原來的 DEMO 已經可以支持旋轉功能了。但是這個旋轉功能還有很多不完善的地方:

  1. 不支持拖拽旋轉。
  2. 旋轉后的放大縮小不正確。
  3. 旋轉后的自動吸附不正確。
  4. 旋轉后八個可伸縮點的光標不正確。

這一小節,我們將逐一解決這四個問題。

拖拽旋轉

拖拽旋轉需要使用 Math.atan2() 函數。

Math.atan2() 返回從原點(0,0)到(x,y)點的線段與x軸正方向之間的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相對于圓點(0,0)的距離。

簡單的說就是以組件中心點為原點 (centerX,centerY),用戶按下鼠標時的坐標設為 (startX,startY),鼠標移動時的坐標設為 (curX,curY)。旋轉角度可以通過 (startX,startY)(curX,curY) 計算得出。

那我們如何得到從點 (startX,startY) 到點 (curX,curY) 之間的旋轉角度呢?

第一步,鼠標點擊時的坐標設為 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出組件中心點:

// 獲取組件中心點位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠標移動時的坐標設為 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分別算出 (startX,startY)(curX,curY) 對應的角度,再將它們相減得出旋轉的角度。另外,還需要注意的就是 Math.atan2() 方法的返回值是一個弧度,因此還需要將弧度轉化為角度。所以完整的代碼為:

// 旋轉前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋轉后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 獲取旋轉的角度值, startRotate 為初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大縮小

組件旋轉后的放大縮小會有 BUG。

從上圖可以看到,放大縮小時會發生移位。另外伸縮的方向和我們拖動的方向也不對。造成這一 BUG 的原因是:當初設計放大縮小功能沒有考慮到旋轉的場景。所以無論旋轉多少角度,放大縮小仍然是按沒旋轉時計算的。

下面再看一個具體的示例:

從上圖可以看出,在沒有旋轉時,按住頂點往上拖動,只需用 y2 - y1 就可以得出拖動距離 s。這時將組件原來的高度加上 s 就能得出新的高度,同時將組件的 top、left 屬性更新。

現在旋轉 180 度,如果這時拖住頂點往下拖動,我們期待的結果是組件高度增加。但這時計算的方式和原來沒旋轉時是一樣的,所以結果和我們期待的相反,組件的高度將會變?。ㄈ绻焕斫膺@個現象,可以想像一下沒有旋轉的那張圖,按住頂點往下拖動)。

如何解決這個問題呢?我從 github 上的一個項目 snapping-demo 找到了解決方案:將放大縮小和旋轉角度關聯起來。

解決方案

下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。

現在我們將一步步分析如何得出拉伸后的組件的正確大小和位移。

第一步,按下鼠標時通過組件的坐標(無論旋轉多少度,組件的 topleft 屬性不變)和大小算出組件中心點:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用當前點擊坐標和組件中心點算出當前點擊坐標的對稱點坐標:

// 獲取畫布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 當前點擊坐標
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 獲取對稱點的坐標
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住組件左上角進行拉伸時,通過當前鼠標實時坐標和對稱點計算出新的組件中心點:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求兩點之間的中點坐標
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于組件處于旋轉狀態,即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉的情況下對其進行計算。

第四步,根據已知的旋轉角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標currentPosition 在未旋轉時的坐標 newTopLeftPoint。同時也能根據已知的旋轉角度、新的組件中心點、對稱點算出組件對稱點sPoint 在未旋轉時的坐標 newBottomRightPoint。

對應的計算公式如下:

/**
 * 計算根據圓心旋轉后的點的坐標
 * @param   {Object}  point  旋轉前的點坐標
 * @param   {Object}  center 旋轉中心
 * @param   {Number}  rotate 旋轉的角度
 * @return  {Object}         旋轉后的坐標
 * https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋轉公式:
     *  點a(x, y)
     *  旋轉中心c(x, y)
     *  旋轉后點n(x, y)
     *  旋轉角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到線性代數中旋轉矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:

通過以上幾個計算值,就可以得到組件新的位移值 topleft 以及新的組件大小。對應的完整代碼如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

現在再來看一下旋轉后的放大縮?。?/p>

自動吸附

自動吸附是根據組件的四個屬性 topleftwidthheight 計算的,在將組件進行旋轉后,這些屬性的值是不會變的。所以無論組件旋轉多少度,吸附時仍然按未旋轉時計算。這樣就會有一個問題,雖然實際上組件的 topleftwidthheight 屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的組件:一個沒旋轉,一個旋轉了 45 度。

可以看出來旋轉后按鈕的 height 屬性和我們從外觀上看到的高度是不一樣的,所以在這種情況下就出現了吸附不正確的 BUG。

解決方案

如何解決這個問題?我們需要拿組件旋轉后的大小及位移來做吸附對比。也就是說不要拿組件實際的屬性來對比,而是拿我們看到的大小和位移做對比。

從上圖可以看出,旋轉后的組件在 x 軸上的投射長度為兩條紅線長度之和。這兩條紅線的長度可以通過正弦和余弦算出,左邊的紅線用正弦計算,右邊的紅線用余弦計算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一樣:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的寬度和高度有了,再根據組件原有的 topleft 屬性,可以得出組件旋轉后新的 topleft 屬性。下面附上完整代碼:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

經過修復后,吸附也可以正常顯示了。

光標

光標和可拖動的方向不對,是因為八個點的光標是固定設置的,沒有隨著角度變化而變化。

解決方案

由于 360 / 8 = 45,所以可以為每一個方向分配 45 度的范圍,每個范圍對應一個光標。同時為每個方向設置一個初始角度,也就是未旋轉時組件每個方向對應的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八個方向
initialAngle: { // 每個點對應的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每個范圍的角度對應的光標
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

計算方式也很簡單:

  1. 假設現在組件已旋轉了一定的角度 a。
  2. 遍歷八個方向,用每個方向的初始角度 + a 得出現在的角度 b。
  3. 遍歷 angleToCursor 數組,看看 b 在哪一個范圍中,然后將對應的光標返回。

經常上面三個步驟就可以計算出組件旋轉后正確的光標方向。具體的代碼如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有負數,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 從上一個命中的角度的索引開始匹配下一個,降低時間復雜度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

從上面的動圖可以看出來,現在八個方向上的光標是可以正確顯示的。

15. 復制粘貼剪切

相對于拖拽旋轉功能,復制粘貼就比較簡單了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

監聽用戶的按鍵操作,在按下特定按鍵時觸發對應的操作。

復制操作

在 vuex 中使用 copyData 來表示復制的數據。當用戶按下 ctrl + c 時,將當前組件數據深拷貝到 copyData。

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同時需要將當前組件在組件數據中的索引記錄起來,在剪切中要用到。

粘貼操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('請選擇組件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘貼時,如果是按鍵操作 ctrl+v。則將組件的 topleft 屬性加 10,以免和原來的組件重疊在一起。如果是使用鼠標右鍵執行粘貼操作,則將復制的組件放到鼠標點擊處。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('請選擇組件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果當前組件索引大于等于插入索引,需要加一,因為當前組件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本質上還是復制,只不過在執行復制后,需要將當前組件刪除。為了避免用戶執行剪切操作后,不執行粘貼操作,而是繼續執行剪切。這時就需要將原先剪切的數據進行恢復。所以復制數據中記錄的索引就起作用了,可以通過索引將原來的數據恢復到原來的位置中。

右鍵操作

右鍵操作和按鍵操作是一樣的,一個功能兩種觸發途徑。

<li @click="copy" v-show="curComponent">復制</li>
<li @click="paste">粘貼</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 數據交互

方式一

提前寫好一系列 ajax 請求API,點擊組件時按需選擇 API,選好 API 再填參數。例如下面這個組件,就展示了如何使用 ajax 請求向后臺交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二適合純展示的組件,例如有一個報警組件,可以根據后臺傳來的數據顯示對應的顏色。在編輯頁面的時候,可以通過 ajax 向后臺請求頁面能夠使用的 websocket 數據:

const data = ['status', 'text'...]

然后再為不同的組件添加上不同的屬性。例如有 a 組件,它綁定的屬性為 status。

// 組件能接收的數據
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在組件中通過 wsKey 獲取這個綁定的屬性。等頁面發布后或者預覽時,通過 weboscket 向后臺請求全局數據放在 vuex 上。組件就可以通過 wsKey 訪問數據了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后臺交互的方式有很多種,不僅僅包括上面兩種,我在這里僅提供一些思路,以供參考。

17. 發布

頁面發布有兩種方式:一是將組件數據渲染為一個單獨的 HTML 頁面;二是從本項目中抽取出一個最小運行時 runtime 作為一個單獨的項目。

這里說一下第二種方式,本項目中的最小運行時其實就是預覽頁面加上自定義組件。將這些代碼提取出來作為一個項目單獨打包。發布頁面時將組件數據以 JSON 的格式傳給服務端,同時為每個頁面生成一個唯一 ID。

假設現在有三個頁面,發布頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據 ID 獲取每個頁面對應的組件數據。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加載

如果自定義組件過大,例如有數十個甚至上百個。這時可以將自定義組件用 import 的方式導入,做到按需加載,減少首屏渲染時間:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本發布

自定義組件有可能會有更新的情況。例如原來的組件使用了大半年,現在有功能變更,為了不影響原來的頁面。建議在發布時帶上組件的版本號:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 組件有兩個版本,在左側組件列表區使用時就可以帶上版本號:

{
  component: 'v-text',
  version: 'v1'
  ...
}

這樣導入組件時就可以根據組件版本號進行導入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

參考資料

查看原文

蔣鵬飛 贊了文章 · 1月18日

可視化拖拽組件庫一些技術要點原理分析(二)

本文是對《可視化拖拽組件庫一些技術要點原理分析》的補充。上一篇文章主要講解了以下幾個功能點:

  1. 編輯器
  2. 自定義組件
  3. 拖拽
  4. 刪除組件、調整圖層層級
  5. 放大縮小
  6. 撤消、重做
  7. 組件屬性設置
  8. 吸附
  9. 預覽、保存代碼
  10. 綁定事件
  11. 綁定動畫
  12. 導入 PSD
  13. 手機模式

現在這篇文章會在此基礎上再補充 4 個功能點,分別是:

  • 拖拽旋轉
  • 復制粘貼剪切
  • 數據交互
  • 發布

和上篇文章一樣,我已經將新功能的代碼更新到了 github:

友善提醒:建議結合源碼一起閱讀,效果更好(這個 DEMO 使用的是 Vue 技術棧)。

14. 拖拽旋轉

在寫上一篇文章時,原來的 DEMO 已經可以支持旋轉功能了。但是這個旋轉功能還有很多不完善的地方:

  1. 不支持拖拽旋轉。
  2. 旋轉后的放大縮小不正確。
  3. 旋轉后的自動吸附不正確。
  4. 旋轉后八個可伸縮點的光標不正確。

這一小節,我們將逐一解決這四個問題。

拖拽旋轉

拖拽旋轉需要使用 Math.atan2() 函數。

Math.atan2() 返回從原點(0,0)到(x,y)點的線段與x軸正方向之間的平面角度(弧度值),也就是Math.atan2(y,x)。Math.atan2(y,x)中的y和x都是相對于圓點(0,0)的距離。

簡單的說就是以組件中心點為原點 (centerX,centerY),用戶按下鼠標時的坐標設為 (startX,startY),鼠標移動時的坐標設為 (curX,curY)。旋轉角度可以通過 (startX,startY)(curX,curY) 計算得出。

那我們如何得到從點 (startX,startY) 到點 (curX,curY) 之間的旋轉角度呢?

第一步,鼠標點擊時的坐標設為 (startX,startY)

const startY = e.clientY
const startX = e.clientX

第二步,算出組件中心點:

// 獲取組件中心點位置
const rect = this.$el.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2

第三步,按住鼠標移動時的坐標設為 (curX,curY)

const curX = moveEvent.clientX
const curY = moveEvent.clientY

第四步,分別算出 (startX,startY)(curX,curY) 對應的角度,再將它們相減得出旋轉的角度。另外,還需要注意的就是 Math.atan2() 方法的返回值是一個弧度,因此還需要將弧度轉化為角度。所以完整的代碼為:

// 旋轉前的角度
const rotateDegreeBefore = Math.atan2(startY - centerY, startX - centerX) / (Math.PI / 180)
// 旋轉后的角度
const rotateDegreeAfter = Math.atan2(curY - centerY, curX - centerX) / (Math.PI / 180)
// 獲取旋轉的角度值, startRotate 為初始角度值
pos.rotate = startRotate + rotateDegreeAfter - rotateDegreeBefore

放大縮小

組件旋轉后的放大縮小會有 BUG。

從上圖可以看到,放大縮小時會發生移位。另外伸縮的方向和我們拖動的方向也不對。造成這一 BUG 的原因是:當初設計放大縮小功能沒有考慮到旋轉的場景。所以無論旋轉多少角度,放大縮小仍然是按沒旋轉時計算的。

下面再看一個具體的示例:

從上圖可以看出,在沒有旋轉時,按住頂點往上拖動,只需用 y2 - y1 就可以得出拖動距離 s。這時將組件原來的高度加上 s 就能得出新的高度,同時將組件的 top、left 屬性更新。

現在旋轉 180 度,如果這時拖住頂點往下拖動,我們期待的結果是組件高度增加。但這時計算的方式和原來沒旋轉時是一樣的,所以結果和我們期待的相反,組件的高度將會變?。ㄈ绻焕斫膺@個現象,可以想像一下沒有旋轉的那張圖,按住頂點往下拖動)。

如何解決這個問題呢?我從 github 上的一個項目 snapping-demo 找到了解決方案:將放大縮小和旋轉角度關聯起來。

解決方案

下面是一個已旋轉一定角度的矩形,假設現在拖動它左上方的點進行拉伸。

現在我們將一步步分析如何得出拉伸后的組件的正確大小和位移。

第一步,按下鼠標時通過組件的坐標(無論旋轉多少度,組件的 topleft 屬性不變)和大小算出組件中心點:

const center = {
    x: style.left + style.width / 2,
    y: style.top + style.height / 2,
}

第二步,用當前點擊坐標和組件中心點算出當前點擊坐標的對稱點坐標:

// 獲取畫布位移信息
const editorRectInfo = document.querySelector('#editor').getBoundingClientRect()

// 當前點擊坐標
const curPoint = {
    x: e.clientX - editorRectInfo.left,
    y: e.clientY - editorRectInfo.top,
}

// 獲取對稱點的坐標
const symmetricPoint = {
    x: center.x - (curPoint.x - center.x),
    y: center.y - (curPoint.y - center.y),
}

第三步,摁住組件左上角進行拉伸時,通過當前鼠標實時坐標和對稱點計算出新的組件中心點:

const curPositon = {
    x: moveEvent.clientX - editorRectInfo.left,
    y: moveEvent.clientY - editorRectInfo.top,
}

const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)

// 求兩點之間的中點坐標
function getCenterPoint(p1, p2) {
    return {
        x: p1.x + ((p2.x - p1.x) / 2),
        y: p1.y + ((p2.y - p1.y) / 2),
    }
}

由于組件處于旋轉狀態,即使你知道了拉伸時移動的 xy 距離,也不能直接對組件進行計算。否則就會出現 BUG,移位或者放大縮小方向不正確。因此,我們需要在組件未旋轉的情況下對其進行計算。

第四步,根據已知的旋轉角度、新的組件中心點、當前鼠標實時坐標可以算出當前鼠標實時坐標currentPosition 在未旋轉時的坐標 newTopLeftPoint。同時也能根據已知的旋轉角度、新的組件中心點、對稱點算出組件對稱點sPoint 在未旋轉時的坐標 newBottomRightPoint。

對應的計算公式如下:

/**
 * 計算根據圓心旋轉后的點的坐標
 * @param   {Object}  point  旋轉前的點坐標
 * @param   {Object}  center 旋轉中心
 * @param   {Number}  rotate 旋轉的角度
 * @return  {Object}         旋轉后的坐標
 * https://www.zhihu.com/question/67425734/answer/252724399 旋轉矩陣公式
 */
export function calculateRotatedPointCoordinate(point, center, rotate) {
    /**
     * 旋轉公式:
     *  點a(x, y)
     *  旋轉中心c(x, y)
     *  旋轉后點n(x, y)
     *  旋轉角度θ                tan ??
     * nx = cosθ * (ax - cx) - sinθ * (ay - cy) + cx
     * ny = sinθ * (ax - cx) + cosθ * (ay - cy) + cy
     */

    return {
        x: (point.x - center.x) * Math.cos(angleToRadian(rotate)) - (point.y - center.y) * Math.sin(angleToRadian(rotate)) + center.x,
        y: (point.x - center.x) * Math.sin(angleToRadian(rotate)) + (point.y - center.y) * Math.cos(angleToRadian(rotate)) + center.y,
    }
}

上面的公式涉及到線性代數中旋轉矩陣的知識,對于一個沒上過大學的人來說,實在太難了。還好我從知乎上的一個回答中找到了這一公式的推理過程,下面是回答的原文:

通過以上幾個計算值,就可以得到組件新的位移值 topleft 以及新的組件大小。對應的完整代碼如下:

function calculateLeftTop(style, curPositon, pointInfo) {
    const { symmetricPoint } = pointInfo
    const newCenterPoint = getCenterPoint(curPositon, symmetricPoint)
    const newTopLeftPoint = calculateRotatedPointCoordinate(curPositon, newCenterPoint, -style.rotate)
    const newBottomRightPoint = calculateRotatedPointCoordinate(symmetricPoint, newCenterPoint, -style.rotate)
  
    const newWidth = newBottomRightPoint.x - newTopLeftPoint.x
    const newHeight = newBottomRightPoint.y - newTopLeftPoint.y
    if (newWidth > 0 && newHeight > 0) {
        style.width = Math.round(newWidth)
        style.height = Math.round(newHeight)
        style.left = Math.round(newTopLeftPoint.x)
        style.top = Math.round(newTopLeftPoint.y)
    }
}

現在再來看一下旋轉后的放大縮?。?/p>

自動吸附

自動吸附是根據組件的四個屬性 topleftwidthheight 計算的,在將組件進行旋轉后,這些屬性的值是不會變的。所以無論組件旋轉多少度,吸附時仍然按未旋轉時計算。這樣就會有一個問題,雖然實際上組件的 topleftwidthheight 屬性沒有變化。但在外觀上卻發生了變化。下面是兩個同樣的組件:一個沒旋轉,一個旋轉了 45 度。

可以看出來旋轉后按鈕的 height 屬性和我們從外觀上看到的高度是不一樣的,所以在這種情況下就出現了吸附不正確的 BUG。

解決方案

如何解決這個問題?我們需要拿組件旋轉后的大小及位移來做吸附對比。也就是說不要拿組件實際的屬性來對比,而是拿我們看到的大小和位移做對比。

從上圖可以看出,旋轉后的組件在 x 軸上的投射長度為兩條紅線長度之和。這兩條紅線的長度可以通過正弦和余弦算出,左邊的紅線用正弦計算,右邊的紅線用余弦計算:

const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)

同理,高度也是一樣:

const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)

新的寬度和高度有了,再根據組件原有的 topleft 屬性,可以得出組件旋轉后新的 topleft 屬性。下面附上完整代碼:

translateComponentStyle(style) {
    style = { ...style }
    if (style.rotate != 0) {
        const newWidth = style.width * cos(style.rotate) + style.height * sin(style.rotate)
        const diffX = (style.width - newWidth) / 2
        style.left += diffX
        style.right = style.left + newWidth

        const newHeight = style.height * cos(style.rotate) + style.width * sin(style.rotate)
        const diffY = (newHeight - style.height) / 2
        style.top -= diffY
        style.bottom = style.top + newHeight

        style.width = newWidth
        style.height = newHeight
    } else {
        style.bottom = style.top + style.height
        style.right = style.left + style.width
    }

    return style
}

經過修復后,吸附也可以正常顯示了。

光標

光標和可拖動的方向不對,是因為八個點的光標是固定設置的,沒有隨著角度變化而變化。

解決方案

由于 360 / 8 = 45,所以可以為每一個方向分配 45 度的范圍,每個范圍對應一個光標。同時為每個方向設置一個初始角度,也就是未旋轉時組件每個方向對應的角度。

pointList: ['lt', 't', 'rt', 'r', 'rb', 'b', 'lb', 'l'], // 八個方向
initialAngle: { // 每個點對應的初始角度
    lt: 0,
    t: 45,
    rt: 90,
    r: 135,
    rb: 180,
    b: 225,
    lb: 270,
    l: 315,
},
angleToCursor: [ // 每個范圍的角度對應的光標
    { start: 338, end: 23, cursor: 'nw' },
    { start: 23, end: 68, cursor: 'n' },
    { start: 68, end: 113, cursor: 'ne' },
    { start: 113, end: 158, cursor: 'e' },
    { start: 158, end: 203, cursor: 'se' },
    { start: 203, end: 248, cursor: 's' },
    { start: 248, end: 293, cursor: 'sw' },
    { start: 293, end: 338, cursor: 'w' },
],
cursors: {},

計算方式也很簡單:

  1. 假設現在組件已旋轉了一定的角度 a。
  2. 遍歷八個方向,用每個方向的初始角度 + a 得出現在的角度 b。
  3. 遍歷 angleToCursor 數組,看看 b 在哪一個范圍中,然后將對應的光標返回。

經常上面三個步驟就可以計算出組件旋轉后正確的光標方向。具體的代碼如下:

getCursor() {
    const { angleToCursor, initialAngle, pointList, curComponent } = this
    const rotate = (curComponent.style.rotate + 360) % 360 // 防止角度有負數,所以 + 360
    const result = {}
    let lastMatchIndex = -1 // 從上一個命中的角度的索引開始匹配下一個,降低時間復雜度
    pointList.forEach(point => {
        const angle = (initialAngle[point] + rotate) % 360
        const len = angleToCursor.length
        while (true) {
            lastMatchIndex = (lastMatchIndex + 1) % len
            const angleLimit = angleToCursor[lastMatchIndex]
            if (angle < 23 || angle >= 338) {
                result[point] = 'nw-resize'
                return
            }

            if (angleLimit.start <= angle && angle < angleLimit.end) {
                result[point] = angleLimit.cursor + '-resize'
                return
            }
        }
    })

    return result
},

從上面的動圖可以看出來,現在八個方向上的光標是可以正確顯示的。

15. 復制粘貼剪切

相對于拖拽旋轉功能,復制粘貼就比較簡單了。

const ctrlKey = 17, vKey = 86, cKey = 67, xKey = 88
let isCtrlDown = false

window.onkeydown = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = true
    } else if (isCtrlDown && e.keyCode == cKey) {
        this.$store.commit('copy')
    } else if (isCtrlDown && e.keyCode == vKey) {
        this.$store.commit('paste')
    } else if (isCtrlDown && e.keyCode == xKey) {
        this.$store.commit('cut')
    }
}

window.onkeyup = (e) => {
    if (e.keyCode == ctrlKey) {
        isCtrlDown = false
    }
}

監聽用戶的按鍵操作,在按下特定按鍵時觸發對應的操作。

復制操作

在 vuex 中使用 copyData 來表示復制的數據。當用戶按下 ctrl + c 時,將當前組件數據深拷貝到 copyData。

copy(state) {
    state.copyData = {
        data: deepCopy(state.curComponent),
        index: state.curComponentIndex,
    }
},

同時需要將當前組件在組件數據中的索引記錄起來,在剪切中要用到。

粘貼操作

paste(state, isMouse) {
    if (!state.copyData) {
        toast('請選擇組件')
        return
    }

    const data = state.copyData.data

    if (isMouse) {
        data.style.top = state.menuTop
        data.style.left = state.menuLeft
    } else {
        data.style.top += 10
        data.style.left += 10
    }

    data.id = generateID()
    store.commit('addComponent', { component: data })
    store.commit('recordSnapshot')
    state.copyData = null
},

粘貼時,如果是按鍵操作 ctrl+v。則將組件的 topleft 屬性加 10,以免和原來的組件重疊在一起。如果是使用鼠標右鍵執行粘貼操作,則將復制的組件放到鼠標點擊處。

剪切操作

cut(state) {
    if (!state.curComponent) {
        toast('請選擇組件')
        return
    }

    if (state.copyData) {
        store.commit('addComponent', { component: state.copyData.data, index: state.copyData.index })
        if (state.curComponentIndex >= state.copyData.index) {
            // 如果當前組件索引大于等于插入索引,需要加一,因為當前組件往后移了一位
            state.curComponentIndex++
        }
    }

    store.commit('copy')
    store.commit('deleteComponent')
},

剪切操作本質上還是復制,只不過在執行復制后,需要將當前組件刪除。為了避免用戶執行剪切操作后,不執行粘貼操作,而是繼續執行剪切。這時就需要將原先剪切的數據進行恢復。所以復制數據中記錄的索引就起作用了,可以通過索引將原來的數據恢復到原來的位置中。

右鍵操作

右鍵操作和按鍵操作是一樣的,一個功能兩種觸發途徑。

<li @click="copy" v-show="curComponent">復制</li>
<li @click="paste">粘貼</li>
<li @click="cut" v-show="curComponent">剪切</li>

cut() {
    this.$store.commit('cut')
},

copy() {
    this.$store.commit('copy')
},

paste() {
    this.$store.commit('paste', true)
},

16. 數據交互

方式一

提前寫好一系列 ajax 請求API,點擊組件時按需選擇 API,選好 API 再填參數。例如下面這個組件,就展示了如何使用 ajax 請求向后臺交互:

<template>
    <div>{{ propValue.data }}</div>
</template>

<script>
export default {
    // propValue: {
    //     api: {
    //             request: a,
    //             params,
    //      },
    //     data: null
    // }
    props: {
        propValue: {
            type: Object,
            default: () => {},
        },
    },
    created() {
        this.propValue.api.request(this.propValue.api.params).then(res => {
            this.propValue.data = res.data
        })
    },
}
</script>

方式二

方式二適合純展示的組件,例如有一個報警組件,可以根據后臺傳來的數據顯示對應的顏色。在編輯頁面的時候,可以通過 ajax 向后臺請求頁面能夠使用的 websocket 數據:

const data = ['status', 'text'...]

然后再為不同的組件添加上不同的屬性。例如有 a 組件,它綁定的屬性為 status。

// 組件能接收的數據
props: {
    propValue: {
        type: String,
    },
    element: {
        type: Object,
    },
    wsKey: {
        type: String,
        default: '',
    },
},

在組件中通過 wsKey 獲取這個綁定的屬性。等頁面發布后或者預覽時,通過 weboscket 向后臺請求全局數據放在 vuex 上。組件就可以通過 wsKey 訪問數據了。

<template>
    <div>{{ wsData[wsKey] }}</div>
</template>

<script>
import { mapState } from 'vuex'

export default {
    props: {
        propValue: {
            type: String,
        },
        element: {
            type: Object,
        },
        wsKey: {
            type: String,
            default: '',
        },
    },
    computed: mapState([
        'wsData',
    ]),
</script>

和后臺交互的方式有很多種,不僅僅包括上面兩種,我在這里僅提供一些思路,以供參考。

17. 發布

頁面發布有兩種方式:一是將組件數據渲染為一個單獨的 HTML 頁面;二是從本項目中抽取出一個最小運行時 runtime 作為一個單獨的項目。

這里說一下第二種方式,本項目中的最小運行時其實就是預覽頁面加上自定義組件。將這些代碼提取出來作為一個項目單獨打包。發布頁面時將組件數據以 JSON 的格式傳給服務端,同時為每個頁面生成一個唯一 ID。

假設現在有三個頁面,發布頁面生成的 ID 為 a、b、c。訪問頁面時只需要把 ID 帶上,這樣就可以根據 ID 獲取每個頁面對應的組件數據。

www.test.com/?id=a
www.test.com/?id=c
www.test.com/?id=b

按需加載

如果自定義組件過大,例如有數十個甚至上百個。這時可以將自定義組件用 import 的方式導入,做到按需加載,減少首屏渲染時間:

import Vue from 'vue'

const components = [
    'Picture',
    'VText',
    'VButton',
]

components.forEach(key => {
    Vue.component(key, () => import(`@/custom-component/${key}`))
})

按版本發布

自定義組件有可能會有更新的情況。例如原來的組件使用了大半年,現在有功能變更,為了不影響原來的頁面。建議在發布時帶上組件的版本號:

- v-text
  - v1.vue
  - v2.vue

例如 v-text 組件有兩個版本,在左側組件列表區使用時就可以帶上版本號:

{
  component: 'v-text',
  version: 'v1'
  ...
}

這樣導入組件時就可以根據組件版本號進行導入:

import Vue from 'vue'
import componentList from '@/custom-component/component-list`

componentList.forEach(component => {
    Vue.component(component.name, () => import(`@/custom-component/${component.name}/${component.version}`))
})

參考資料

查看原文

贊 26 收藏 17 評論 0

蔣鵬飛 關注了用戶 · 1月15日

SHERlocked93 @sherlocked93

來自南京的前端打字員,掘金優秀作者,慕課暢銷專欄 <JavaScript 設計模式精講> 作者,原創同步更新于 Github 個人博客 (求 star?? )

公眾號 前端下午茶,歡迎關注 ?? ,分享前端相關的技術博客、精選文章,期待在這里和大家一起進步 ~

關注 391

認證與成就

  • 獲得 1049 次點贊
  • 獲得 7 枚徽章 獲得 0 枚金徽章, 獲得 2 枚銀徽章, 獲得 5 枚銅徽章

擅長技能
編輯

開源項目 & 著作
編輯

注冊于 2020-05-26
個人主頁被 23.4k 人瀏覽

一本到在线是免费观看_亚洲2020天天堂在线观看_国产欧美亚洲精品第一页_最好看的2018中文字幕 <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <蜘蛛词>| <文本链> <文本链> <文本链> <文本链> <文本链> <文本链>