你幫家人維護(hù)著那份精心設(shè)計(jì)的HTML簡歷:整體排版已經(jīng)不用再動,內(nèi)容也是他們自己的故事。可每當(dāng)需要調(diào)整一下項(xiàng)目列表的順序、刪掉過期的鏈接、或者加一行新的證書,問題就來了——明明動動手指就能改好的事情,卻非得經(jīng)過你:接收修改意見、打開編輯器、改完后再傳回去。一輪又一輪的“發(fā)我改,我改完發(fā)你”,硬是把小事拖成了循環(huán)噩夢。
我自己就踩過這個(gè)坑。那份簡歷是一個(gè)獨(dú)立的靜態(tài)HTML文件,設(shè)計(jì)稿早就定好了。但就是這種看似零碎的改動,反復(fù)了十幾次之后,我忽然意識到,瓶頸從來不是排版,而是編輯的閉環(huán)——內(nèi)容的主人知道要怎么改,卻偏偏缺一把能直接在頁面上動手的剪刀。于是,一個(gè)念頭浮上來:為什么不讓這個(gè)頁面自己就能編輯自己呢?
![]()
這個(gè)問題的答案,最終落在了一個(gè)HTML屬性、大約一百行純JavaScript代碼、以及零構(gòu)建工具上。做出來的東西讓任何人都能在瀏覽器里修改頁面上的任何文字、重新排序或刪除任意內(nèi)容塊、添加新信息、編輯超鏈接、控制打印分頁,甚至直接打印為PDF。而一旦刷新頁面,又會毫發(fā)無傷地回到原始文件。這篇文章,就是要在你的靜態(tài)HTML頁面上,搭出這么一套瀏覽器內(nèi)的編輯層。
需要跟上思路的話,你得有一些HTML和CSS的基本功,能理解CSS Grid和@media print的用法;還得大致知道JavaScript操作DOM是怎么回事,比如querySelector、事件監(jiān)聽、動態(tài)創(chuàng)建元素。全程不需要任何框架、庫或者編譯工具——這正是它的巧妙之處。
在展開技術(shù)細(xì)節(jié)之前,值得先聊聊一個(gè)常見的質(zhì)疑:干嘛不直接用Word、Google Docs,或者搭個(gè)CMS?那些方案當(dāng)然能解決一部分問題,但它們往往綁定賬號、要服務(wù)器、有復(fù)雜的界面,還要學(xué)習(xí)一套新的操作邏輯。而這里要實(shí)現(xiàn)的目標(biāo)非常單薄:讓一個(gè)已經(jīng)設(shè)計(jì)好的HTML頁面,具備極輕量的編輯能力,編輯完之后繼續(xù)以純HTML文件的形式存在,不需要在任何服務(wù)端保存數(shù)據(jù)。這種“用完即走”的體量,恰好是傳統(tǒng)編輯器和內(nèi)容管理系統(tǒng)最不擅長的地方。但也有人會擔(dān)心,內(nèi)容不持久保存,豈不是白干?這恰好是另一個(gè)維度上的優(yōu)勢——任何誤刪、改壞了,只要刷新就能還原,對于臨時(shí)的、偶爾用一下的編輯場景來說,這種魯棒性反而比自動保存更加安全。
所以,我們的判斷是:這并非一個(gè)替代CMS的通用編輯器,而是一套為特定靜態(tài)頁面量身定制的臨時(shí)編輯工具。它的存在不依賴后臺,不產(chǎn)生新文件,卻能把編輯權(quán)交還給真正需要改內(nèi)容的那個(gè)人。明白了這個(gè)定位,接下來的每一步就都有了依據(jù)。
讓我們從一切的起點(diǎn)開始:contenteditable。這是一個(gè)HTML屬性,把它設(shè)為true之后,瀏覽器就會把對應(yīng)的元素變成一個(gè)可以直接打字的編輯區(qū)域。插入光標(biāo)、選擇文本、打字、刪除、剪貼板操作,甚至Ctrl+Z的撤銷歷史——瀏覽器全都幫你包圓了。放在我們的場景里,只需要把整個(gè)內(nèi)容容器寫上contenteditable="true",再關(guān)掉拼寫檢查的標(biāo)記,像這樣:
單憑這一個(gè)屬性能做的事情,其實(shí)比很多人預(yù)想的要強(qiáng)。點(diǎn)擊任意一個(gè)段落,光標(biāo)就到位了;按下回車鍵,如果光標(biāo)在無序列表里,瀏覽器會自動創(chuàng)建一個(gè)新的列表項(xiàng);在列表末尾再按一下回車,還能優(yōu)雅地退出列表。這種對HTML語義的原生支持,讓“按回車增加一個(gè)要點(diǎn)”這樣的基礎(chǔ)操作完全不需要寫一行代碼。
可是,contenteditable的能耐也就到此為止了。它對“內(nèi)容塊”毫無概念。它不會幫你把一個(gè)工作經(jīng)歷條目挪到另一個(gè)前面,也不會在你想刪除一張卡片時(shí)把整張卡片干凈地移除。它甚至不知道什么是可重復(fù)的布局單元——它只是讓整個(gè)區(qū)域變成了一個(gè)富文本編輯器,所有的結(jié)構(gòu)變動都得靠你自己動手。這就引出了我們的核心任務(wù):在contenteditable的肩頭上,搭建一個(gè)理解頁面結(jié)構(gòu)、并且能夠操作這些結(jié)構(gòu)的薄薄的編輯層。
要讓內(nèi)容塊能夠被重新排序,第一步就得先把標(biāo)記準(zhǔn)備妥當(dāng)。在靜態(tài)頁面里,重復(fù)出現(xiàn)的內(nèi)容往往采用相似的HTML模式,比如簡歷里的每條工作經(jīng)歷,或者菜單上的每個(gè)菜品。如果這些塊隨意堆疊,當(dāng)我們需要用JavaScript移動它們時(shí),就很容易搞亂兄弟節(jié)點(diǎn)之間的順序。因此,一個(gè)可靠的約定是:所有想要支持重排的內(nèi)容塊,都應(yīng)當(dāng)是同一個(gè)父容器下的直接子元素,并且每個(gè)塊自身具有一致的標(biāo)簽結(jié)構(gòu)和可識別的類名。例如,可以把每一段經(jīng)歷都包在一個(gè)
里,類名統(tǒng)一設(shè)為“entry”,這樣它們就成了排排坐的平等元素。
準(zhǔn)備工作的另一面,是確保內(nèi)容的獨(dú)立性。因?yàn)楹罄m(xù)的移動操作本質(zhì)上就是改變DOM節(jié)點(diǎn)在父容器中的位置,這就要求單個(gè)塊在脫離上下文后,其內(nèi)部結(jié)構(gòu)和樣式依然能完好渲染。CSS中最好不要寫出依賴特定兄弟順序的選擇器,比如用:nth-child來決定樣式,否則挪動位置后外觀就可能亂掉。另外,如果頁面使用了CSS Grid或Flexbox來控制整體網(wǎng)格,那么元素的順序本身就可以通過order屬性或網(wǎng)格排列來調(diào)整,這也為增強(qiáng)排序的視覺效果留出了空間。
有了清晰的塊結(jié)構(gòu),接下來就是給每個(gè)塊裝上控制手柄。我們希望鼠標(biāo)懸停在某個(gè)塊上時(shí),能出現(xiàn)“上移”“下移”“刪除”之類的小按鈕。要批量完成這件事,不能給每個(gè)塊單獨(dú)寫監(jiān)聽器,而是要用一個(gè)可復(fù)用的函數(shù),遍歷所有標(biāo)記為可操作的元素,為它們動態(tài)創(chuàng)建控件容器,并將按鈕的行為與DOM操作關(guān)聯(lián)起來。
這個(gè)函數(shù)的邏輯可以這樣梳理:對于每一個(gè)塊,創(chuàng)建一個(gè)絕對定位的小工具欄,內(nèi)含三個(gè)按鈕,分別對應(yīng)向上移動、向下移動、刪除。把工具欄追加到塊內(nèi)部,默認(rèn)隱藏。當(dāng)鼠標(biāo)進(jìn)入這個(gè)塊的范圍,就顯示工具欄;鼠標(biāo)移出則隱藏。為了得到平滑的體驗(yàn),還需要處理工具欄本身冒泡引起的顯示與隱藏抖動,可以借助事件委托或者CSS的pointer-events屬性來避免。
移動塊的操作核心就是節(jié)點(diǎn)在父容器中的相鄰關(guān)系:向上移動,就是把當(dāng)前節(jié)點(diǎn)插入到前一個(gè)兄弟節(jié)點(diǎn)之前;向下移動,則是插入到后一個(gè)兄弟節(jié)點(diǎn)之后;而刪除就更直接,調(diào)用removeChild把整個(gè)塊從DOM中摘除。值得注意的是,這種基于DOM的編輯并不會觸發(fā)任何數(shù)據(jù)持久化,所以刪除和移動都是暫時(shí)性的,刷新后一切復(fù)原。
但在實(shí)現(xiàn)上還有一個(gè)細(xì)微但影響體驗(yàn)的問題:如果鼠標(biāo)懸停在一個(gè)嵌套較深的元素上,比如一個(gè)塊內(nèi)部的某個(gè)列表項(xiàng),那么我們究竟應(yīng)該顯示哪個(gè)層級的工具欄?用戶通常希望只看到最內(nèi)層那個(gè)“可修改”單元的控件,而不是父塊、祖父塊上的控件同時(shí)冒出來。這時(shí)候,CSS的:has()偽類就能派上大用場。我們可以定義一個(gè)規(guī)則:當(dāng)塊元素本身被懸停,且其內(nèi)部沒有任何子塊元素也處于懸停狀態(tài)時(shí),才顯示它的工具欄。換句話說,如果鼠標(biāo)進(jìn)入了一個(gè)嵌套更深的子塊,父塊的工具欄就自動隱藏,只讓最內(nèi)層的工具欄浮現(xiàn)。
這樣一條規(guī)則寫成CSS大致是:
.block:has(:hover) .toolbar { display: none; }.block:hover:not(:has(.block:hover)) .toolbar { display: block; }
第一個(gè)規(guī)則的意思是:只要某個(gè)塊內(nèi)部有任何元素處于懸停狀態(tài),就先把自己的工具欄藏起來;第二個(gè)規(guī)則過濾出“自身被懸停”而且“內(nèi)部沒有其他同名塊元素被懸停”的塊,才顯示工具欄。這樣一來,層級控制就被完全交給了聲明式的樣式,JavaScript只需要關(guān)心添加和移除塊本身,而不必在事件里做復(fù)雜的遍歷判斷。
當(dāng)用戶可以自由上移、下移和刪除塊之后,另一個(gè)極其現(xiàn)實(shí)的需求就浮出來了:怎么編輯一個(gè)超鏈接的網(wǎng)址?在contenteditable區(qū)域內(nèi),用戶可以直接修改鏈接的文字,但是要修改href屬性,單純靠光標(biāo)是辦不到的。我們需要專門為此設(shè)計(jì)一種交互:當(dāng)用戶點(diǎn)擊一個(gè)鏈接時(shí),不是打開那個(gè)鏈接,而是彈出一個(gè)輸入框,讓用戶修改目標(biāo)地址。
另一個(gè)在紙質(zhì)輸出時(shí)格外重要的控制,是打印分頁。靜態(tài)HTML頁面在打印時(shí),瀏覽器會根據(jù)內(nèi)容自動分頁,但分頁位置往往不能盡如人意,比如把某個(gè)條目標(biāo)題孤零零地落在頁尾。如果能讓編輯者自行指定從哪里強(qiáng)制分頁,問題就化解了。利用CSS的@media print規(guī)則,結(jié)合一個(gè)特定的類名如“page-break”,我們可以讓擁有這個(gè)類的元素在打印時(shí)產(chǎn)生分頁。然后,在瀏覽器的編輯模式下,給每個(gè)塊旁邊增加一個(gè)“在此分頁”的按鈕,點(diǎn)擊后為這個(gè)塊添加或移除“page-break”類,用戶就能直觀地在屏幕上看到分頁標(biāo)記(可以用一條虛線表示),并且打印時(shí)自動生效。
對應(yīng)的CSS可能長這樣:
@media print {
特別聲明:以上內(nèi)容(如有圖片或視頻亦包括在內(nèi))為自媒體平臺“網(wǎng)易號”用戶上傳并發(fā)布,本平臺僅提供信息存儲服務(wù)。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.