在日常開發中,我們經常會有格式化的需求,如日期格式化、數字格式化、錢幣格式化等等。
格式化器的作用似乎跟轉換器的作用類似,但是它們的關注點卻不一樣:
Spring有自己的格式化器抽象org.springframework.format.Formatter,但是談到格式化器,必然就會聯想起來JDK自己的java.text.Format體系。為后文做好鋪墊,本文就先介紹下JDK為我們提供了哪些格式化能力。
Java里從來都缺少不了字符串拼接的活,JDK也提供了多種“工具”供我們使用,如:StringBuffer、StringBuilder以及最直接的+號,相信這些大家都有用過。但這都不是本文的內容,本文將講解格式化器,給你提供一個新的思路來拼接字符串,并且是推薦方案。
JDK內置有格式化器,便是java.text.Format體系。它是個抽象類,提供了兩個抽象方法:
public abstract class Format implements Serializable, Cloneable {
public abstract StringBuffer format(Object obj, StringBuffer toAppendTo, FieldPosition pos);
public abstract Object parseObject (String source, ParsePosition pos);
}
Java SE針對于Format抽象類對于常見的應用場景分別提供了三個子類實現:
抽象類。用于用于格式化日期/時間類型java.util.Date。雖然是抽象類,但它提供了幾個靜態方法用于獲取它的實例:
// 格式化日期 + 時間
public final static DateFormat getInstance() {
return getDateTimeInstance(SHORT, SHORT);
}
public final static DateFormat getDateTimeInstance(int dateStyle, int timeStyle, Locale aLocale){
return get(timeStyle, dateStyle, 3, aLocale);
}
// 格式化日期
public final static DateFormat getDateInstance(int style, Locale aLocale) {
return get(0, style, 2, aLocale);
}
// 格式化時間
public final static DateFormat getTimeInstance(int style, Locale aLocale){
return get(style, 0, 1, aLocale);
}
有了這些靜態方法,你可在不必關心具體實現的情況下直接使用:
/**
* {@link DateFormat}
*/
@Test
public void test1() {
Date curr=new Date();
// 格式化日期 + 時間
System.out.println(DateFormat.getInstance().getClass() + "-->" + DateFormat.getInstance().format(curr));
System.out.println(DateFormat.getDateTimeInstance().getClass() + "-->" + DateFormat.getDateTimeInstance().format(curr));
// 格式化日期
System.out.println(DateFormat.getDateInstance().getClass() + "-->" + DateFormat.getDateInstance().format(curr));
// 格式化時間
System.out.println(DateFormat.getTimeInstance().getClass() + "-->" + DateFormat.getTimeInstance().format(curr));
}
運行程序,輸出:
class java.text.SimpleDateFormat-->20-12-25 上午7:19
class java.text.SimpleDateFormat-->2020-12-25 7:19:30
class java.text.SimpleDateFormat-->2020-12-25
class java.text.SimpleDateFormat-->7:19:30
嗯,可以看到底層實現其實是咱們熟悉的SimpleDateFormat。實話說,這種做法不常用,狠一點:基本不會用(框架開發者可能會用做兜底實現)。
一般來說,我們會直接使用SimpleDateFormat來對Date進行格式化,它可以自己指定Pattern,個性化十足。如:
@Test
public void test2() {
DateFormat dateFormat=new SimpleDateFormat("yyyy-MM-dd"); // yyyy-MM-dd HH:mm:ss
System.out.println(dateFormat.format(new Date()));
}
運行程序,輸出:
2020-12-25
關于SimpleDateFormat的使用方式不再啰嗦,不會的就可走自行勸退手續了。此處只提醒一點:SimpleDateFormat線程不安全。
說明:JDK 8以后不再建議使用Date類型,也就不會再使用到DateFormat。同時我個人建議:在項目中可強制嚴令禁用
抽象類。用于格式化數字,它可以對數字進行任意格式化,如小數、百分數、十進制數等等。它有兩個實現類:
類結構和DateFormat類似,也提供了getXXXInstance靜態方法給你直接使用,無需關心底層實現:
@Test
public void test41() {
double myNum=1220.0455;
System.out.println(NumberFormat.getInstance().getClass() + "-->" + NumberFormat.getInstance().format(myNum));
System.out.println(NumberFormat.getCurrencyInstance().getClass() + "-->" + NumberFormat.getCurrencyInstance().format(myNum));
System.out.println(NumberFormat.getIntegerInstance().getClass() + "-->" + NumberFormat.getIntegerInstance().format(myNum));
System.out.println(NumberFormat.getNumberInstance().getClass() + "-->" + NumberFormat.getNumberInstance().format(myNum));
System.out.println(NumberFormat.getPercentInstance().getClass() + "-->" + NumberFormat.getPercentInstance().format(myNum));
}
運行程序,輸出:
class java.text.DecimalFormat-->1,220.045
class java.text.DecimalFormat-->¥1,220.05
class java.text.DecimalFormat-->1,220
class java.text.DecimalFormat-->1,220.045
class java.text.DecimalFormat-->122,005%
這一看就知道DecimalFormat是NumberFormat的主力了。
Decimal:小數,小數的,十進位的。
用于格式化十進制數字。它具有各種特性,可以解析和格式化數字,包括:西方數字、阿拉伯數字和印度數字。它還支持不同種類的數字,包括:整數(123)、小數(123.4)、科學記數法(1.23E4)、百分數(12%)和貨幣金額(3)。所有這些都可以進行本地化。
下面是它的構造器:
其中最為重要的就是這個pattern(不帶參數的構造器一般不會用),它表示格式化的模式/模版。一般來說我們對DateFormat的pattern比較熟悉,但對數字格式化的模版符號了解甚少。這里我就幫你整理出這個表格(信息源自JDK官網),記得收藏哦:
說明:Number和Digit的區別:
Number是個抽象概念,其表達形式可以是數字、手勢、聲音等等。如1024就是個numberDigit是用來表達的單獨符號。如0-9這是個digit就可以用來表示number,如1024就是由1、0、2、4這四個digit組成的
看了這個表格的符號規則,估計很多同學還是一臉懵逼。不啰嗦了,上干貨
這是最經典、最常見的使用場景,甚至來說你有可能職業生涯只會用到此場景。
/**
* {@link DecimalFormat}
*/
@Test
public void test4() {
double myNum=1220.0455;
System.out.println("===============0的使用===============");
System.out.println("只保留整數部分:" + new DecimalFormat("0").format(myNum));
System.out.println("保留3位小數:" + new DecimalFormat("0.000").format(myNum));
System.out.println("整數部分、小數部分都5位。不夠的都用0補位(整數高位部,小數低位補):" + new DecimalFormat("00000.00000").format(myNum));
System.out.println("===============#的使用===============");
System.out.println("只保留整數部分:" + new DecimalFormat("#").format(myNum));
System.out.println("保留2為小數并以百分比輸出:" + new DecimalFormat("#.##%").format(myNum));
// 非標準數字(不建議這么用)
System.out.println("===============非標準數字的使用===============");
System.out.println(new DecimalFormat("666").format(myNum));
System.out.println(new DecimalFormat(".6666").format(myNum));
}
運行程序,輸出:
===============0的使用===============只保留整數部分:1220
保留3位小數:1220.045
整數部分、小數部分都5位。不夠的都用0補位(整數高位部,小數低位補):01220.04550===============#的使用===============只保留整數部分:1220
保留2為小數并以百分比輸出:122004.55%===============非標準數字的使用===============661220
1220.666
通過此案例,大致可得出如下結論:
如果你不是在證券/銀行行業,這個大概率是用不著的(即使在,你估計也不會用它)。來幾個例子感受一把就成:
@Test
public void test5() {
double myNum=1220.0455;
System.out.println(new DecimalFormat("0E0").format(myNum));
System.out.println(new DecimalFormat("0E00").format(myNum));
System.out.println(new DecimalFormat("00000E00000").format(myNum));
System.out.println(new DecimalFormat("#E0").format(myNum));
System.out.println(new DecimalFormat("#E00").format(myNum));
System.out.println(new DecimalFormat("#####E00000").format(myNum));
}
運行程序,輸出:
1E3
1E03
12200E-00001
.1E4
.1E04
1220E00000
分組分隔符比較常用,它就是我們常看到的逗號,
@Test
public void test6() {
double myNum=1220.0455;
System.out.println(new DecimalFormat(",###").format(myNum));
System.out.println(new DecimalFormat(",##").format(myNum));
System.out.println(new DecimalFormat(",##").format(123456789));
// 分隔符,左邊是無效的
System.out.println(new DecimalFormat("###,##").format(myNum));
}
運行程序,輸出:
1,220
12,20
1,23,45,67,89
12,20
在展示層面也比較常用,用于把一個數字用%形式表示出來。
@Test
public void test42() {
double myNum=1220.0455;
System.out.println("百分位表示:" + new DecimalFormat("#.##%").format(myNum));
System.out.println("千分位表示:" + new DecimalFormat("#.##\u2030").format(myNum));
}
運行程序,輸出:
百分位表示:122004.55%
千分位表示:1220045.5‰
嗯,這個符號¤,鍵盤竟無法直接輸出,得使用軟鍵盤(建議使用copy大法)。
@Test
public void test7() {
double myNum=1220.0455;
System.out.println(new DecimalFormat(",000.00¤").format(myNum));
System.out.println(new DecimalFormat(",000.¤00").format(myNum));
System.out.println(new DecimalFormat("¤,000.00").format(myNum));
System.out.println(new DecimalFormat("¤,000.¤00").format(myNum));
// 世界貨幣表達形式
System.out.println(new DecimalFormat(",000.00¤¤").format(myNum));
}
運行程序,輸出:
1,220.05¥
1,220.05¥
¥1,220.05
1,220.05¥¥
¥1,220.05¥
1,220.05CNY
注意最后一條結果:如果連續出現兩次,代表貨幣符號的國際代號。
說明:結果默認都做了Locale本地化處理的,若你在其它國家就不會再是¥人民幣符號嘍
DecimalFormat就先介紹到這了,其實掌握了它就基本等于掌握了NumberFormat。接下來再簡要看看它另外一個“兒子”:ChoiceFormat。
Choice:精選的,仔細推敲的。
這個格式化器非常有意思:相當于以數字為鍵,字符串為值的鍵值對。使用一組double類型的數組作為鍵,一組String類型的數組作為值,兩數組相同(不一定必須是相同,見示例)索引值的元素作為一對。
@Test
public void test8() {
double[] limits={1, 2, 3, 4, 5, 6, 7};
String[] formats={"周一", "周二", "周三", "周四", "周五", "周六", "周天"};
NumberFormat numberFormat=new ChoiceFormat(limits, formats);
System.out.println(numberFormat.format(1));
System.out.println(numberFormat.format(4.3));
System.out.println(numberFormat.format(5.8));
System.out.println(numberFormat.format(9.1));
System.out.println(numberFormat.format(11));
}
運行程序,輸出:
周一
周四
周五
周天
周天
結果解釋:
可能你會想這有什么使用場景???是的,不得不承認它的使用場景較少,本文下面會介紹下它和MessageFormat的一個使用場景。
如果說DateFormat和NumberFormat都用沒什么花樣,主要記住它的pattern語法格式就成,那么就下來這個格式化器就是本文的主菜了,使用場景非常的廣泛,它就是MessageFormat。
MessageFormat提供了一種與語言無關(不管你在中國還是其它國家,效果一樣)的方式生成拼接消息/拼接字符串的方法。使用它來構造顯示給最終用戶的消息。MessageFormat接受一組對象,對它們進行格式化,然后在模式的適當位置插入格式化的字符串。
先來個最簡單的使用示例體驗一把:
/**
* {@link MessageFormat}
*/
@Test
public void test9() {
String sourceStrPattern="Hello {0},my name is {1}";
Object[] args=new Object[]{"girl", "YourBatman"};
String formatedStr=MessageFormat.format(sourceStrPattern, args);
System.out.println(formatedStr);
}
運行程序,輸出:
Hello girl,my name is YourBatman
有沒有中似曾相似的感覺,是不是和String.format()的作用特別像?是的,它倆的用法區別,到底使用稅文下也會討論。
要熟悉MessageFormat的使用,主要是要熟悉它的參數模式(你也可以理解為pattern)。
MessageFormat采用{}來標記需要被替換/插入的部分,其中{}里面的參數結構具有一定模式:
ArgumentIndex[,FormatType[,FormatStyle]]
說明:FormatType和FormatStyle只有在傳入值為日期時間、數字、百分比等類型時才有可能需要設置,使用得并不多。畢竟:我在外部格式化好后再放進去不香嗎?
@Test
public void test10() {
MessageFormat messageFormat=new MessageFormat("Hello, my name is {0}. I’am {1,number,#.##} years old. Today is {2,date,yyyy-MM-dd HH:mm:ss}");
// 亦可通過編程式 顯示指定某個位置要使用的格式化器
// messageFormat.setFormatByArgumentIndex(1, new DecimalFormat("#.###"));
System.out.println(messageFormat.format(new Object[]{"YourBatman", 24.123456, new Date()}));
}
運行程序,輸出:
Hello, my name is YourBatman. I’am 24.12 years old. Today is 2020-12-26 15:24:28
它既可以直接在模版里指定格式化模式類型,也可以通過API方法set指定格式化器,當然你也可以在外部格式化好后再放進去,三種方式均可,任君選擇。
下面基于此示例,對MessageFormat的使用注意事項作出幾點強調。
@Test
public void test11() {
System.out.println(MessageFormat.format("{1} - {1}", new Object[]{1})); // {1} - {1}
System.out.println(MessageFormat.format("{0} - {1}", new Object[]{1})); // 輸出:1 - {1}
System.out.println(MessageFormat.format("{0} - {1}", new Object[]{1, 2, 3})); // 輸出:1 - 2
System.out.println("---------------------------------");
System.out.println(MessageFormat.format("'{0} - {1}", new Object[]{1, 2})); // 輸出:{0} - {1}
System.out.println(MessageFormat.format("''{0} - {1}", new Object[]{1, 2})); // 輸出:'1 - 2
System.out.println(MessageFormat.format("'{0}' - {1}", new Object[]{1, 2})); // {0} - 2
// 若你數據庫值兩邊都需要''包起來,請你這么寫
System.out.println(MessageFormat.format("''{0}'' - {1}", new Object[]{1, 2})); // '1' - 2
System.out.println("---------------------------------");
System.out.println(MessageFormat.format("0} - {1}", new Object[]{1, 2})); // 0} - 2
System.out.println(MessageFormat.format("{0 - {1}", new Object[]{1, 2})); // java.lang.IllegalArgumentException: Unmatched braces in the pattern.
}
我們知道MessageFormat提供有一個static靜態方法,非常方便地的使用:
public static String format(String pattern, Object ... arguments) {
MessageFormat temp=new MessageFormat(pattern);
return temp.format(arguments);
}
可以清晰看到,該靜態方法本質上還是構造了一個MessageFormat實例去做格式化的。因此:若你要多次(如高并發場景)格式化同一個模版(參數可不一樣)的話,那么提前創建好一個全局的(非static) MessageFormat實例再執行格式化是最好的,而非一直調用其靜態方法。
說明:若你的系統非高并發場景,此性能損耗基本無需考慮哈,怎么方便怎么來。畢竟朝生夕死的對象對JVM來說沒啥壓力
二者都能用于字符串拼接(格式化)上,撇開MessageFormat支持各種模式不說,我們只需要考慮它倆的性能上差異。
一說到正則表達式,我心里就發怵,因為它對性能是不友好的,所以孰優孰劣,高下立判。
說明:還是那句話,沒有絕對的誰好誰壞,如果你的系統對性能不敏感,那就是方便第一
這個就很多啦,最常見的有:HTML拼接、SQL拼接、異常信息拼接等等。
比如下面這個SQL拼接:
StringBuilder sb=new StringBuilder();
sb.append("insert into user (");
sb.append(" name,");
sb.append(" accountId,");
sb.append(" zhName,");
sb.append(" enname,");
sb.append(" status");
sb.append(") values (");
sb.append(" ''{0}'',");
sb.append(" {1},");
sb.append(" ''{2}'',");
sb.append(" ''{3}'',");
sb.append(" {4},");
sb.append(")");
Object[] args={name, accountId, zhName, enname, status};
// 最終SQL
String sql=MessageFormat.format(sb.toString(), arr);
你看,多工整。
說明:如果值是字符串需要'包起來,那么請使用兩邊各兩個包起來
本文內容介紹了JDK原生的格式化器知識點,主要作用在這三個方面:
Spring是直接面向使用者的框架產品,很顯然這些是不夠用的,并且JDK的格式化器在設計上存在一些弊端。比如經常被吐槽的:日期/時間類型格式化器SimpleDateFormat為毛在java.text包里,而它格式化的類型Date卻在java.util包內,這實為不合適。
有了JDK格式化器作為基礎,下篇我們就可以浩浩蕩蕩地走進Spring格式化器的大門了,看看它是如何優于JDK進行設計和抽象的。
作者:YourBatman
原文鏈接:https://fangshixiang.blog.csdn.net/article/details/111752597
說起富文本編輯器,我們大都遇到過,甚至使用過,這種所見即所得的書寫方式,以及它靈活的排版,讓我們的創作更加流暢和美觀。其實你可以把它理解成是把word等軟件的功能轉成在瀏覽器里面使用,這樣就能通過其他的一些手段進行管理,并融入到相應系統中。但是由于實現方式和語言等的不同,存在著一些出入。
比如我現在正在使用的,也就是此刻我寫這篇文章的工具,就是一個富文本編輯器。其實富文本編輯器有很多種,它們的功能類似、產出目的類似、使用方式也類似,只不過在豐富程度上稍有差別,今天的CKEditor5就是其中的一款。
示意圖
可以看到,還是很好看的,美而不失實用。它的功能特別多,只不過有一些功能是要收費的,也就是說它只開源了一部分,或者說對于一些更高級的吊吊的功能你需要少買一點零食或者玩具。不過這些基礎功能已經足夠用了,它的可插拔式插件集成功能非常強大。
示意圖
就像上面所示,你可以隨意的添加或刪除一個擴展功能,下面有非常多的待繼承插件供你選擇。
示意圖
但是像上面這種的,帶有premium的插件,那你就需要支付一定的費用才可以使用啦。
細心的你相信一眼就看出來了,這就是我們今天要講的內容:從word中導入。
這是一個高級功能,雖然不是很常用,但是有一些特殊的場景或者需求,我們可能希望從編輯好的word中,通過導入的方式來讓用戶在網頁中繼續編輯它,并盡可能的保留內容和格式。
一個是自己資金不是很充裕,再一個是想自己去動手做做,因此就決定獨立實現這樣一個功能。自己做的,當然可以隨便免費用。
在開始之前,我們先看下做這個功能在完成之后需要滿足的效果,雖然這個功能官網是收費的,但是為了給大家演示,官網也提供了示例,我們先看下官網的成品:
效果圖
我們先根據提示,在官網示例上面下載了它提供的一個word,然后用CKEditor5的導入word功能,把這個word導入到編輯器中,解析完成之后就看到了效果,它的還原度很高了,官網應該是特意制作的示例word文件,里邊包含了段落、列表、圖片、表格等等多個技術點。這些都是我們接下來要實現的內容,官網這復雜程度,錢花的挺值。
為了能讓大家有一個對比,這里我把原版word也展示出來給你們看一下:
效果圖
可以對比著感受下,不過還是有一些地方不太一樣的,比如我對這個原文檔做一點點更改。體現就稍微有一點略微的不同,但是這個不是毛病,只是看著有點別扭,我給兩張圖,先來原word的圖,這是我改過的列表:
示意圖
再來一張官網導入之后渲染的效果圖:
示意圖
主要有:1.列表距左邊的距離。2.列表項之間多出空白。3.不能顯示中文序號。
我們要想實現這樣一個插件,首先想到有沒有現成的word轉html的前端或者后端插件,因為富文本編輯器是可以設置內容的,并且這個內容實質就是html代碼,然后再在這個基礎上進行集成開發。
因為我有自己的node后端,所以如果用后端做的話就找了一些關于node的word轉html插件,一共找到了docx2html、mammoth、word2html等,但是經過測試都不太理想,于是決定放棄,換一個思路,我們可以解析word,然后根據word規范,自己生成出html。
word是流式文件,能任意編輯并且回顯,那么肯定有一套約定在里邊,能夠保存格式并重新讀取,就看它有沒有開放給我們,幸好,docx這個x就是告訴我們,可以的,因為它就是xml的意思,符合xml規范。
好了,我們可以找出兩個輔助插件:
第一個就是用來解壓縮用的adm-zip包。
第二個就是用來解析xml文件的xml-js包。
為什么這樣呢?這是因為一個docx文件,就是一個壓縮包,我們把docx文件重命名為zip格式。然后就可以解壓看下里面的內容:
示意圖
這就是解壓之后的目錄,里面包含著所有的word內容,我們一會揭開它的面紗。其中一個關鍵目錄就是word文件夾:
示意圖
可以看到有很多的xml文件,它們就規定了word的回顯機制和渲染邏輯。
還有一個media文件夾,我們看下它里面有什么:
示意圖
可以明顯的看到有兩張圖片,這兩張圖片就是我們在原word中使用的圖片,它就隱藏在這里。
另外,其中document.xml文件存儲了整個word的結構和內容,numbering.xml文件規定了列表如何渲染,styles.xml告訴了需要應用哪些樣式。
我們就以document.xml文件做一個簡單的說明,其余不做過多展開:
示意圖
文件前面是對該xml的一些聲明,body中包含了一個個的段落,也就是w:p。其中又包含了多個系列w:r,系列中就存儲著我們的文本,比如上圖紅框中我圈出的部分。
而且里面還存儲著段落屬性w:pPr和系列屬性w:rPr。我們就是通過對這些一對對的xml標簽,來對word進行解析,找出它的渲染規則。
首先使用上面提到的兩個包,非常簡單:
const dir=join(process.cwd(), 'public/temp/word/' + fn)
const zip=new AdmZip(dir)
let contentXml=zip.readAsText('word/document.xml')
const documentData=xml2js(contentXml)
contentXml=zip.readAsText('word/numbering.xml')
const numberingData=contentXml ? xml2js(contentXml) : {
elements: ''
}
contentXml=zip.readAsText('word/_rels/document.xml.rels')
const relsData=xml2js(contentXml)
contentXml=zip.readAsText('word/styles.xml')
const styleData=xml2js(contentXml)
let ent=zip.getEntries()
let ind=fn.lastIndexOf('.')
let flag=false
for(let i=0; i < ent.length; i++) {
let n=ent[i].entryName
if(n.substring(0, 11)==='word/media/') {
flag=true
zip.extractEntryTo(n, join(process.cwd(), 'public/temp/word/' + fn.substring(0, ind)), false, true)
}
}
return {
documentXML: documentData?.elements[0]?.elements[0]?.elements,
numberingXML: numberingData?.elements[0]?.elements,
relsXML: relsData?.elements[0]?.elements,
styleXML: styleData?.elements[0]?.elements.slice(2),
imagePath: fn.substring(0, ind),
}
簡單對上面的代碼做一下說明:
至此,我們看一下目前解析完成之后,形成的數據結構。
示意圖
很好,現在開始集成:
import { Editor } from '/lib/ckeditor5/ckeditor'
import loadConfig from './config'
import filePlugin from './file'
import './style.scss'
loadConfig(Editor)
const container: any=ref(null)
let richEditor: any=null
onMounted(()=> {
Editor.create(container.value, {
extraPlugins: [filePlugin]
}).then((editor: any)=> {
richEditor=editor
}).catch((error: any)=> {
console.log(error.stack)
})
})
第1行,導入Editor,也就是我們一會要用的富文本編輯器,然后第9行通過create方法創建它,接收的兩個參數分別表示:渲染的容器與配置的插件。
因為CKEditor5填入圖片的時候,需要自己手動實現一個插件方法,因此我們要把它配置進來,因為跟咱們要講的內容無關,就不展開了,官方文檔說的很清楚了。
第5行,我在初始化編輯器之前,先去加載了一些配置,其中一個就是引入word轉pdf的功能,由于CKEditor5插件擴展很容易,直接在Editor的builtinPlugins屬性數據里面加上我們實現的插件就可以,所以我們直接講插件的開發:
import { ButtonView, Plugin } from '/lib/ckeditor5/ckeditor'
import { postData } from '@/request'
import { DocumentWordProcessorReference } from '@/common/svg'
import { serverUrl } from '@/company'
import { ElMessage } from 'element-plus'
import { arrayToMapByKey } from '@/utils'
let numberingList: any=null
let relsList: any=null
let styleList: any=null
let imageUrl: any=null
let docInfo: any={
author: {},
currentAuthor: '',
currentIndex: -1
}
const colorList=['#d13438', '#0078d4', '#5c2e91', 'chocolate', 'aquamarine', 'lawngreen', 'hotpink', 'darkblue', 'darkslateblue', 'blueviolet', 'firebrick', 'coral', 'darkcyan', 'indigo', 'greenyellow', 'deeppink', 'indianred', 'blue', 'darkgray', 'darkmagenta', 'darkgreen', 'chartreuse', 'darksalmon', 'dimgray', 'crimson', 'darkolivegreen', 'gold', 'aqua', 'lightcoral', 'goldenrod', 'burlywood', 'green', 'darkkhaki', 'forestgreen', 'fushcia', 'darkorchid', 'deepskyblue', 'darkgoldenrod', 'cyan', 'cornflowerblue', 'brown', 'cadetblue', 'darkviolet', 'dodgerblue', 'darkred', 'gray', 'khaki', 'bisque', 'darkorange', 'darkslategray', 'lightblue', 'darkturquoise', 'darkseagreen']
let BlockType=''
引入一些必要的組件和方法等,然后定義我們的插件,一定要繼承ckeditor5的Plugin:
export default class importFromWord extends Plugin {
}
然后首先在里面實現它的init方法,做一些初始化操作:
init() {
const editor=this.editor
editor.ui.componentFactory.add('importFromWord', ()=> {
const button=new ButtonView()
button.set({
label: '從word導入',
icon: DocumentWordProcessorReference,
tooltip: true
})
button.on('execute', ()=> {
this.input.click()
})
return button
})
}
this.editor就是我們之前使用create創建好的編輯器,通過editor.ui.componentFactory.add給工具欄添加一個按鈕,也就是我們要點擊導入word的按鈕。
示意圖
這里面用到了ckeditor5的ButtonView按鈕組件生成器,設置它的名稱和圖標,然后添加一個暴露出來的事件,當點擊按鈕的時候,觸發選擇文件彈窗,這個input是我自己寫的一個文件上傳輸入框。
接下來,我們去構造函數中做一些事情,當實例化這個組件的時候,初始化好我們需要的東西:
constructor(editor: any) {
super(editor)
this.editor=editor
this.input=document.createElement('input')
this.input.type='file'
this.input.style.opacity=0
this.input.style.display='none'
this.input.addEventListener('change', (e: any)=> {
const formData: any=new FormData()
formData.append("upload", this.input.files[0])
formData.Headers={'Content-Type':'multipart/form-data'}
let ms=ElMessage({
message: "正在解析...",
type: "info",
})
postData({
service: "lc",
url: `file/word`,
data: formData,
}).then(res=> {
ms.close()
if (res.data) {
ElMessage({
message: "上傳文件成功",
type: "success",
})
const { documentXML, numberingXML, relsXML, styleXML, imagePath }=res.data
numberingList=numberingXML
relsList=relsXML
styleList=styleXML
imageUrl=imagePath
markList(documentXML)
const html=listToHTML(documentXML)
const ckC=this.editor.ui.view?.editable?.element
const ckP=this.editor.ui.view?.stickyPanel?.element
if(ckC) {
let rt=ckC.parentNode.parentNode.parentNode
rt.style.setProperty('--content-top', docInfo.paddingTop + 'px')
rt.style.setProperty('--content-right', docInfo.paddingRight + 'px')
rt.style.setProperty('--content-bottom', docInfo.paddingBottom + 'px')
rt.style.setProperty('--content-left', docInfo.paddingLeft + 'px')
rt.style.setProperty('--content-width', docInfo.pageWidth - docInfo.paddingLeft - docInfo.paddingRight + 'px')
}
if(ckP) {
let rt=ckP.parentNode.parentNode.parentNode
rt.style.setProperty('--sticky-width', docInfo.pageWidth + 'px')
}
const div=document.createElement('div')
div.style.display='none'
div.innerHTML=html
splitList(div.firstElementChild)
insertDivToList(div)
document.body.appendChild(div)
document.body.removeChild(div)
this.editor.setData(div.innerHTML)
} else {
ElMessage({
message: "上傳文件失敗",
type: "error",
})
}
})
})
}
在這里我們主要做了幾件事:
首先第4行到第7行定義了一個文件選擇器。
然后給這個輸入框添加了一個事件。
第9行到第20行我們讀取到選擇的文件并上傳到服務器進行解析。
對返回回來的文檔數據,我們首先做一個標記,以方便我們接下來的操作:
function markList(list: any) {
let cache: any=[]
list.forEach((item: any, index: number)=> {
let isList=false
if(item.name==='w:p') {
let pPr=findByName(item.elements, 'w:pPr')
if(pPr) {
let numPr=findByName(pPr.elements, 'w:numPr')
if(numPr) {
isList=true
let ilvl=numPr.elements[0].attributes['w:val']
let numId=numPr.elements[1].attributes['w:val']
let c=cache.at(-1)
numPr.level=ilvl
if(c) {
if(c.ilvl===ilvl && c.numId===numId) {
cache.pop()
}else if(c.ilvl===ilvl && c.numId !==numId) {
numPr.start=true
c.numPr.end=true
cache.pop()
}else if(c.ilvl < ilvl && c.numId===numId) {
numPr.start=true
cache.pop()
}else if(c.ilvl > ilvl && c.numId===numId) {
c.numPr.end=true
cache.pop()
}else if(c.numId !==numId) {
while(c.ilvl >=ilvl) {
c.numPr.end=true
c=cache.pop()
if(!c) {
break
}
}
}
}else {
numPr.start=true
}
cache.push({
ilvl,
numId,
index,
numPr
})
}
}
}
})
cache.forEach((c: any)=> {
c.numPr.end=true
})
}
主要就是對列表進行標記,因為它要做一些特殊化的處理。
拿到數據之后,我們的核心邏輯都在第33行,實現listToHtml進行處理:
function listToHTML(list: any) {
let html=''
list.forEach((item: any, index: number)=> {
let info=getContainer(item)
html +=info
})
return html
}
遍歷每一項,然后把它們生成的html拼接起來:
function getContainer(item: any) {
let html=''
if(item.name==='w:p') {
let n=findByName(item.elements, 'w:pPr')
let el: any=null
let pEl: any=null
let attr: any={}
let style=null
if(n) {
let ps=findByName(n.elements, 'w:pStyle')
if(ps) {
let styleId=getAttributeVal(ps)
let sy=styleList.find((item: any)=> {
return item.attributes['w:styleId']===styleId
})
let ppr=findByName(sy.elements, 'w:pPr')
let rpr=findByName(sy.elements, 'w:rPr')
if(ppr) {
ppr.elements.forEach((p: any)=> {
if(!findByName(n.elements, p.name)) {
n.elements.push(p)
}
})
}
if(rpr) {
let rs=findsByName(item.elements, 'w:r')
rs.forEach((r: any)=> {
let rr=findByName(r.elements, 'w:rPr')
rpr.elements.forEach((p: any)=> {
if(!findByName(rr.elements, p.name)) {
rr.elements.push(p)
}
})
})
}
}
let info=getPAttribute(n.elements)
attr=info.attr
style=info.style
if(attr.list) {
let s1: any={}
let s2: any={}
for(let t in info.style) {
if(t==='list-style-type') {
s1[t]=info.style[t]
}else{
s2[t]=info.style[t]
}
}
for(let t in info.liStyle) {
s1[t]=info.liStyle[t]
}
if(attr.order) {
if(attr.start) {
if(attr.level !=='0') {
html +='<li style="list-style-type:none;">'
}
html +='<ol'
html +=addStyle(s1)
html +='<li>'
html +='<p'
html +=addStyle(s2)
}else {
html +='<li>'
html +='<p'
html +=addStyle(s2)
}
}else{
if(attr.start) {
if(attr.level !=='0') {
html +='<li style="list-style-type:none;">'
}
html +='<ul'
html +=addStyle(s1)
html +='<li>'
html +='<p'
html +=addStyle(s2)
}else {
html +='<li>'
html +='<p'
html +=addStyle(s2)
}
}
}else{
html +='<p'
html +=addStyle(info.style)
}
}else{
el=document.createElement('p')
}
item.elements.forEach((r: any)=> {
if(r.name==='w:ins') {
setAuthor(r.attributes['w:author'])
r.elements.forEach((ins: any)=> {
html +=dealWr(ins, 'ins')
})
}else if(r.name==='w:hyperlink') {
r.elements.forEach((hyp: any)=> {
html +=dealWr(hyp)
})
}else if(r.name==='w:r') {
html +=dealWr(r)
}else if(r.name==='w:commentRangeStart') {
BlockType='comment'
}else if(r.name==='w:commentRangeEnd') {
BlockType=''
}else if(r.name==='w:del') {
setAuthor(r.attributes['w:author'])
r.elements.forEach((hyp: any)=> {
html +=dealWr(hyp, 'del')
})
}
})
if(attr.list) {
if(attr.order) {
if(attr.end) {
html +='</p></li></ol>'
if(attr.level !=='0') {
html +='</li>'
}
}else {
html +='</p></li>'
}
}else{
if(attr.end) {
html +='</p></li></ul>'
if(attr.level !=='0') {
html +='</li>'
}
}else {
html +='</p></li>'
}
}
}else {
html +='</p>'
}
}else if(item.name==='w:tbl') {
let n=findByName(item.elements, 'w:tblPr')
if(n) {
let info=getTableAttribute(n.elements)
html +='<figure class="table"'
html +=addStyle(info.figureStyle)
html +='<table'
html +=addStyle(info.tableStyle)
html +='<tbody>'
}
item.elements.forEach((r: any)=> {
if(r.name==='w:tr') {
html +=dealWtr(r)
}
})
html +='</tbody></table></figure>'
}else if(item.name==='w:sectPr') {
let ps=findByName(item.elements, 'w:pgSz')
let pm=findByName(item.elements, 'w:pgMar')
if(ps) {
docInfo.pageWidth=Math.ceil(ps.attributes['w:w'] / 20 * 96 / 72) + 1
}
if(pm) {
docInfo.paddingTop=pm.attributes['w:top'] / 1440 * 96
docInfo.paddingRight=pm.attributes['w:right'] / 1440 * 96
docInfo.paddingBottom=pm.attributes['w:bottom'] / 1440 * 96
docInfo.paddingLeft=pm.attributes['w:left'] / 1440 * 96
}
}
return html
}
做了一些邏輯判斷,和不同標簽的特殊處理。
在剛才input事件中的第34行到47行,主要是做一些編輯器大小等外觀設置,因為要配置成word中的寬度與邊距。
還需要考慮到,列表可能不是連續的,中間可能被一些段落所隔開,因此到這里還需要對生成的html中的列表進行分割,并修復索引問題:
function splitList(el: any) {
while(el) {
if(el.tagName==='OL' || el.tagName==='UL') {
let a=el.querySelectorAll('ol > p, ul > p')
let path: any=[]
a.forEach((item: any)=> {
let p: any=[]
while(item) {
p.push(item)
item=item.parentNode
if(item===el) {
break
}
}
path.push(p.reverse())
})
let cur=el
let t: number=0
path.forEach((p: any)=> {
let list=cur.cloneNode(false)
let list2=list
cur.parentNode.insertBefore(list, cur)
p.forEach((l: any, ind: number)=> {
let chi=cur.children
let t=0
for(let i=0; i < chi.length; i++) {
if(chi[i] !==l) {
list.append(chi[i])
t++
i--
}else{
if(cur.tagName==='OL') {
let s=cur.getAttribute('start')
cur.setAttribute('start', s ? (+s + t) : (t + 1))
}
if(ind===p.length - 1) {
let par=chi[i].parentNode
el.parentNode.insertBefore(chi[i], el)
if(par.children.length===0) {
par.remove()
}
cur=el
}else{
cur.setAttribute('start', cur.getAttribute('start') - 1)
let cl=chi[i].cloneNode(false)
list.append(cl)
list=cl
cur=chi[i]
}
break
}
}
})
})
}
el=el.nextElementSibling
}
}
并且由于CKEditor5會對相鄰的列表進行合并等處理,這不是我們想要的,可以在它們中間插入一些div:
function insertDivToList(div: any) {
let f=div.firstElementChild
let k=f.nextElementSibling
while(k) {
if(f.tagName==='UL' && k.tagName==='UL') {
let d=document.createElement('div')
f=k
div.insertBefore(d, f)
k=f.nextElementSibling
}else if(f.tagName==='OL' && k.tagName==='OL') {
let d=document.createElement('p')
d.setAttribute('list-separator', "true")
f=k
div.insertBefore(d, f)
k=f.nextElementSibling
}else {
f=k
k=f.nextElementSibling
}
}
}
最后我們用this.editor.setData方法,將剛才生成的html設置到編輯器中去。
到此我們基本就已經把需要的功能實現了。
該來看一下我們所做的工作成果了,首先同樣導入CKEditor5官網中的文檔:
效果圖
可以看到,內容與格式等,基本跟原word一樣,與CKEditor5官網的示例也相同。然后我們再用另一個剛才修改過的文件測試一下:
效果圖
這個是用咱們剛才開發的插件導入的word的效果圖,幾乎與原word一模一樣,也沒有了CKEditor官網中的那幾個小問題。
至此,我們針對CKEditor5導入word的功能已經開發完畢,同時我又找了各種類型的word測試,均未發現問題,還原度都非常高。
感謝docx的規范,使得我們自己解析word成為可能,雖然不可能100%還原word的格式,但是能夠將它導入到我們的富文本編輯器中,以進行二次創作,這對我們來說是非常方便的。
本次word轉html,并導入富文本編輯器的開發過程,希望能給大家帶來啟發。
每一次創作都是快樂的,每一次分享也都是有益的,希望能夠幫助到你!
謝謝
端路由 前端路由是后來發展到SPA(單頁應用)時才出現的概念。 SPA 就是一個WEB項目只有一個 HTML 頁面,一旦頁面加載完成,SPA 不會因為用戶的操作而進行頁面的重新加載或跳轉。 前端路由在SPA項目中是必不可少的,頁面的跳轉、刷新都與路由有關,通過不同的url顯示相應的頁面。 優點:前后端的徹底分離,不刷新頁面,用戶體驗較好,頁面持久性較好。 后端路由 當在地址欄切換不同的url時,都會向服務器發送一個請求,服務器接收并響應這個請求,在服務端拼接好html文件返回給頁面來展示。 優點:減輕了前端的壓力,html都由后端拼接; 缺點:依賴于網絡,網速慢,用戶體驗很差,項目比較龐大時,服務器端壓力較大, 不能在地址欄輸入指定的url訪問相應的模塊,前后端不分離。 路由模式 前端路由實現起來其實很簡單,本質是監聽 URL 的變化,然后匹配路由規則,在不刷新的情況下顯示相應的頁面。 hash模式(對應HashHistory)
// onhashchage事件,可以在window對象上監聽這個事件
window.onhashchange=function(event){
console.log(event.oldURL, event.newURL)
let hash=location.hash.slice(1)
}
history模式 (對應HTML5History)
history.replaceState({}, null, '/b') // 替換路由
history.pushState({}, null, '/a') // 路由壓棧,記錄瀏覽器的歷史棧 不刷新頁面
history.back() // 返回
history.forward() // 前進
history.go(-2) // 后退2次
history.pushState 修改瀏覽器地址,而頁面的加載是通過 onpopstate 事件監聽實現,加載對應的頁面內容,完成頁面更新。
// 頁面加載完畢 first.html
history.pushState({page: 1}, "", "first.html");
window.onpopstate=function(event) {
// 根據當前 URL 加載對應頁面
loadPage(location.pathname);
};
// 點擊跳轉到 second.html
history.pushState({page: 2}, "", "second.html");
function loadPage(url) {
// 加載 url 對應頁面內容
// 渲染頁面
}
onpopstate 事件是瀏覽器歷史導航的核心事件,它標識了頁面狀態的變化時機。通過監聽這個時機,根據最新的狀態信息更新頁面 當使用 history.pushState() 或 history.replaceState() 方法修改瀏覽器的歷史記錄時,不會直接觸發 onpopstate 事件。 但是,可以在調用這些方法時將數據存儲在歷史記錄條目的狀態對象中, onpopstate 事件在處理程序中訪問該狀態對象。這樣,就可以在不觸發 onpopstate 事件的情況下更新頁面內容,并獲取到相應的狀態值。 history 模式下 404 頁面的處理 在 history 模式下,瀏覽器會向服務器發起請求,服務器根據請求的路徑進行匹配: 如果服務器無法找到與請求路徑匹配的資源或路由處理器,服務器可以返回 /404 路由,跳轉到項目中配置的 404 頁面,指示該路徑未找到。 對于使用歷史路由模式的單頁應用(SPA),通常會在服務器配置中添加一個通配符路由,將所有非靜態資源的請求都重定向到主頁或一個自定義的 404 頁面,以保證在前端處理路由時不會出現真正的 404 錯誤頁面。 在項目中配置對應的 404 頁面:
export const publicRoutes=[
{
path: '/404',
component: ()=> import('src/views/404/index'),
},
]
vueRouter Vue Router 是 Vue.js 的官方路由。它與 Vue.js 核心深度集成,允許你在 Vue 應用中構建單頁面應用(SPA),并且提供了靈活的路由配置和導航功能。讓用 Vue.js 構建單頁應用變得輕而易舉。功能包括:
路由組件
$router 、$route $route: 是當前路由信息對象,獲取和當前路由有關的信息。 route 為屬性是只讀的,里面的屬性是 immutable (不可變) 的,不過可以通過 watch 監聽路由的變化。
fullPath: "" // 當前路由完整路徑,包含查詢參數和 hash 的完整路徑
hash: "" // 當前路由的 hash 值 (錨點)
matched: [] // 包含當前路由的所有嵌套路徑片段的路由記錄
meta: {} // 路由文件中自賦值的meta信息
name: "" // 路由名稱
params: {} // 一個 key/value 對象,包含了動態片段和全匹配片段就是一個空對象。
path: "" // 字符串,對應當前路由的路徑
query: {} // 一個 key/value 對象,表示 URL 查詢參數。跟隨在路徑后用'?'帶的參數
$router是 vueRouter 實例對象,是一個全局路由對象,通過 this.$router 訪問路由器, 可以獲取整個路由文件或使用路由提供的方法。
// 導航守衛
router.beforeEach((to, from, next)=> {
/* 必須調用 `next` */
})
router.beforeResolve((to, from, next)=> {
/* 必須調用 `next` */
})
router.afterEach((to, from)=> {})
動態導航到新路由
router.push
router.replace
router.go
router.back
router.forward
routes 是 router 路由實例用來配置路由對象 可以使用路由懶加載(動態加載路由)的方式
const router=new VueRouter({
routes: [
{
path: '/home',
name: 'Home',
component:()=import('../views/home')
}
]
})
頁面中路由展示位置
<div id="app">
<!-- 添加路由 -->
<!-- 會被渲染為 <a href="#/home"></a> -->
<router-link to="/home">Home</router-link>
<router-link to="/login">Login</router-link>
<!-- 展示路由的內容 -->
<router-view></router-view>
</div>
路由模塊 引入 vue-router,使用 Vue.use(VueRouter) 注冊路由插件 定義路由數組,并將數組傳入VueRouter 實例,并將實例暴露出去
import Vue from 'vue'
import VueRouter from 'vue-router'
import { hasVisitPermission, isWhiteList } from './permission'
// 注冊路由組件
Vue.use(VueRouter)
// 創建路由: 每一個路由規則都是一個對象
const routers=[
// path 路由的地址
// component 路由的所展示的組件
{
path: '/',
// 當訪問 '/'的時候 路由重定向 到新的地址 '/home'
redirect: '/home',
},
{
path: '/home',
component: home,
},
{
path: '/login',
component: login,
},
],
// 實例化 VueRouter 路由
const router=new VueRouter({
mode: 'history',
base: '/',
routers
})
// 路由守衛
router.beforeEach(async (to, from, next)=> {
// 清除面包屑導航數據
store.commit('common/SET_BREAD_NAV', [])
// 是否白名單
if (isWhiteList(to)) {
next()
} else {
// 未登錄,先登錄
try {
if (!store.state.user.userInfo) {
await store.dispatch('user/getUserInfo')
}
// 登錄后判斷,是否有訪問頁面的權限
if (!hasVisitPermission(to, store.state.user.userInfo)) {
next({ path: '/404' })
} else {
next()
}
} catch (err) {
$error(err)
}
}
})
export default router
在 main.js 上掛載路由 將VueRouter實例引入到main.js,并注冊到根Vue實例上
import router from './router'
new Vue({
router,
store,
render: h=> h(App),
}).$mount('#app')
動態路由 我們經常需要把某種模式匹配到的所有路由,全都映射到同個組件。例如,我們有一個 User 組件,對于所有 ID 各不相同的用戶,都要使用這個組件來渲染。我們可以在 vueRrouter 的路由路徑中使用“動態路徑參數”(dynamic segment) 來達到這個效果。
當一個路由被匹配時,它的 params 的值將在每個組件中以 this.$route.query 的形式暴露出來。因此,我們可以通過更新 User 的模板來呈現當前的用戶 ID:
const routes=[
{
path: '/user/:id'
name: 'User'
components: User
}
]
_vue-router _通過配置 _params _和 _query _來實現動態路由
// 傳遞參數
this.$router.push({
name: Home,
params: {
number: 1 ,
code: '999'
}
})
// 接收參數
const p=this.$route.params
// 方式一:路由拼接
this.$router.push('/home?username=xixi&age=18')
// 方式二:name + query 傳參
this.$router.push({
name: Home,
query: {
username: 'xixi',
age: 18
}
})
// 方式三:path + name 傳參
this.$router.push({
path: '/home',
query: {
username: 'xixi',
age: 18
}
})
// 接收參數
const q=this.$route.query
keep-alive keep-alive是vue中的內置組件,能在組件切換過程中將狀態保留在內存中,防止重復渲染DOM。 keep-alive 包裹動態組件時,會緩存不活動的組件實例,而不是銷毀它們。 和 transition 相似,keep-alive 是一個抽象組件:它自身不會渲染一個 DOM 元素,也不會出現在組件的父組件鏈中。 keep-alive 可以設置以下props屬性:
在不緩存組件實例的情況下,每次切換都會重新 render,執行整個生命周期,每次切換時,重新 render,重新請求,必然不滿足需求。 會消耗大量的性能 keep-alive 的基本使用 只是在進入當前路由的第一次render,來回切換不會重新執行生命周期,且能緩存router-view的數據。 通過 include 來判斷是否匹配緩存的組件名稱: 匹配首先檢查組件自身的 name 選項,如果 name 選項不可用,則匹配它的局部注冊名稱 (父組件 components 選項的鍵值),匿名組件不能被匹配
<keep-alive>
<router-view></router-view>
</keep-alive>
<keep-alive include="a,b">
<component :is="view"></component>
</keep-alive>
<!-- 正則表達式 (使用 `v-bind`) -->
<keep-alive :include="/a|b/">
<component :is="view"></component>
</keep-alive>
<!-- 數組 (使用 `v-bind`) -->
<keep-alive :include="['a', 'b']">
<component :is="view"></component>
</keep-alive>
在路由中設置 keepAlive 屬性判斷是否需要緩存
{
path: 'list',
name: 'itemList', // 列表頁
component (resolve) {
require(['@/pages/item/list'], resolve)
},
meta: {
keepAlive: true,
compName: 'ItemList'
title: '列表頁'
}
}
{
path: 'management/class_detail/:id/:activeIndex/:status',
name: 'class_detail',
meta: {
title: '開班詳情',
keepAlive: true,
compName: 'ClassInfoDetail',
hideInMenu: true,
},
component: ()=> import('src/views/classManage/class_detail.vue'),
},
使用
<div id="app" class='wrapper'>
<keep-alive>
<!-- 需要緩存的視圖組件 -->
<router-view v-if="$route.meta.keepAlive"></router-view>
</keep-alive>
<!-- 不需要緩存的視圖組件 -->
<router-view v-if="!$route.meta.keepAlive"></router-view>
</div>
keepAlive 對生命周期的影響 設置緩存后組件加載的生命周期會新增 actived 與 deactived
keep-alive 組件監聽 include 及 exclude 的緩存規則,若發生變化則執行 pruneCache (遍歷cache 的name判斷是否需要緩存,否則將其剔除) 且 keep-alive 中沒有 template,而是用了 render,在組件渲染的時候會自動執行 render 函數,
動態路由緩存的的具體表現在:
如何刪除 keep-alive 中的緩存 vue2 中清除路由緩存
在組件內可以通過 this 獲取 vuerouter 的緩存
vm.$vnode.parent.componentInstance.cache
或者通過 ref 獲取 外級 dom
添加圖片注釋,不超過 140 字(可選)
<template>
<el-container id="app-wrapper">
<Aside />
<el-container>
<el-header id="app-header" height="45px">
<Header @removeCacheRoute="removeCacheRoute" />
</el-header>
<!-- {{ includeViews }} -->
<el-main id="app-main">
<keep-alive :include="includeViews">
<router-view ref="routerViewRef" :key="key" />
</keep-alive>
</el-main>
</el-container>
</el-container>
</template>
<script>
import Aside from './components/Aside'
import Header from './components/Header'
import { mapGetters } from 'vuex'
export default {
name: 'Layout',
components: {
Aside,
Header,
},
data () {
return {
}
},
computed: {
...mapGetters(['cacheRoute', 'excludeRoute']),
includeViews () {
return this.cacheRoute.map(item=> item.compName)
},
key () {
return this.$route.fullPath
},
},
methods: {
removeCacheRoute (fullPath) {
const cache=this.$refs.routerViewRef.$vnode.parent.componentInstance.cache
delete cache[fullPath]
},
},
}
</script>
路由守衛 導航守衛主要用來通過跳轉或取消的方式守衛導航。有多種機會植入路由導航過程中:全局的, 單個路由獨享的, 或者組件級的。 通俗來講:路由守衛就是路由跳轉過程中的一些生命周期函數(鉤子函數),我們可以利用這些鉤子函數幫我們實現一些需求。 路由守衛又具體分為 全局路由守衛、獨享守衛 及 組件路由守衛。 全局路由守衛
beforeEach(to,from, next) 在路由跳轉前觸發,參數包括to,from,next 三個,這個鉤子作用主要是用于登錄驗證。 前置守衛也可以理解為一個路由攔截器,也就是說所有的路由在跳轉前都要先被前置守衛攔截。
router.beforeEach(async (to, from, next)=> {
// 清除面包屑導航數據
store.commit('common/SET_BREAD_NAV', [])
// 是否白名單
if (isWhiteList(to)) {
next()
} else {
// 未登錄,先登錄
try {
if (!store.state.user.userInfo) {
await store.dispatch('user/getUserInfo')
// 登錄后判斷,是否有角色, 無角色 到平臺默認頁
if (!store.state.user.userInfo.permissions || !store.state.user.userInfo.permissions.length) {
next({ path: '/noPermission' })
}
}
// 登錄后判斷,是否有訪問頁面的權限
if (!hasVisitPermission(to, store.state.user.userInfo)) {
next({ path: '/404' })
} else {
next()
}
} catch (err) {
$error(err)
}
}
})
beforeResolve(to,from, next) 在每次導航時都會觸發,區別是在導航被確認之前,同時在所有組件內守衛和異步路由組件被解析之后,解析守衛就被正確調用。 即在 beforeEach 和 組件內 beforeRouteEnter 之后,afterEach之前調用。 router.beforeResolve 是獲取數據或執行任何其他操作的理想位置
router.beforeResolve(async to=> {
if (to.meta.requiresCamera) {
try {
await askForCameraPermission()
} catch (error) {
if (error instanceof NotAllowedError) {
// ... 處理錯誤,然后取消導航
return false
} else {
// 意料之外的錯誤,取消導航并把錯誤傳給全局處理器
throw error
}
}
}
})
和beforeEach相反,他是在路由跳轉完成后觸發,參數包括to, from 由于此時路由已經完成跳轉 所以不會再有next。
全局后置守衛對于分析、更改頁面標題、聲明頁面等輔助功能以及許多其他事情都很有用。
router.afterEach((to, from)=> {
// 在路由完成跳轉后執行,實現分析、更改頁面標題、聲明頁面等輔助功能
sendToAnalytics(to.fullPath)
})
beforeEnter(to,from, next) 獨享路由守衛可以直接在路由配置上定義,但是它只在進入路由時觸發,不會在 params、query 或 hash 改變時觸發。
const routes=[
{
path: '/users/:id',
component: UserDetails,
// 在路由配置中定義守衛
beforeEnter: (to, from,next)=> {
next()
},
},
]
或是使用數組的方式傳遞給 beforeEnter ,有利于實現路由守衛的重用
function removeQueryParams(to) {
if (Object.keys(to.query).length)
return { path: to.path, query: {}, hash: to.hash }
}
function removeHash(to) {
if (to.hash) return { path: to.path, query: to.query, hash: '' }
}
const routes=[
{
path: '/users/:id',
component: UserDetails,
beforeEnter: [removeQueryParams, removeHash],
},
{
path: '/about',
component: UserDetails,
beforeEnter: [removeQueryParams],
},
]
組件路由守衛 在組件內使用的鉤子函數,類似于組件的生命周期, 鉤子函數執行的順序包括
組件內路由守衛的執行時機:
<template>
...
</template>
export default{
data(){
//...
},
// 在渲染該組件的對應路由被驗證前調用
beforeRouteEnter (to, from, next) {
// 此時 不能獲取組件實例 this
// 因為當守衛執行前,組件實例還沒被創建
next((vm)=>{
// next 回調 在 組件 beforeMount 之后執行 此時組件實例已創建,
// 可以通過 vm 訪問組件實例
console.log('A組件中的路由守衛==>> beforeRouteEnter 中next 回調 vm', vm)
)
},
// 可用于檢測路由的變化
beforeRouteUpdate (to, from, next) {
// 在當前路由改變,但是該組件被復用時調用 此時組件已掛載完可以訪問組件實例 `this`
// 舉例來說,對于一個帶有動態參數的路徑 /foo/:id,在 /foo/1 和 /foo/2 之間跳轉的時候,
// 由于會渲染同樣的 Foo 組件,因此組件實例會被復用。而這個鉤子就會在這個情況下被調用。
console.log('組件中的路由守衛==>> beforeRouteUpdate')
next()
},
// 在導航離開渲染該組件的對應路由時調用
beforeRouteLeave (to, from, next) {
// 可以訪問組件實例 `this`
console.log('A組件中的路由守衛==>> beforeRouteLeave')
next()
}
}
<style>
...
</style>
注意 beforeRouteEnter 是支持給 next 傳遞回調的唯一守衛。對于 beforeRouteUpdate 和 beforeRouteLeave 來說,this 已經可用了,所以不支持 傳遞回調,因為沒有必要了
頁面加載時路由守衛觸發順序:
添加圖片注釋,不超過 140 字(可選)
當點擊切換路由時: A頁面跳轉至B頁面觸發的生命周期及路由守衛順序:
添加圖片注釋,不超過 140 字(可選)
路由守衛的觸發順序 beforeRouterLeave-->beforeEach-->beforeEnter-->beforeRouteEnter-->beforeResolve-->afterEach--> beforeCreate (新)-->created (新)-->beforeMount(新) -->beforeRouteEnter中的next回調 -->beforeDestory(舊)-->destoryed(舊)-->mounted(新) 當路由更新時:觸發 beforeRouteUpdate 注意: 但凡涉及到有next參數的鉤子,必須調用next() 才能繼續往下執行下一個鉤子,否則路由跳轉等會停止。 vueRouter 實現原理 vueRouter 實現的原理就是 監聽瀏覽器中 url 的 hash值變化,并切換對應的組件 1.路由注冊 通過vue.use()安裝vue-router插件,會執行install方法,并將Vue當做參數傳入install方法 Vue.use(VueRouter)===VueRouter.install() src/install.js
export function install (Vue) {
// 確保 install 調用一次
if (install.installed && _Vue===Vue) return
install.installed=true
// 把 Vue 賦值給全局變量
_Vue=Vue
const registerInstance=(vm, callVal)=> {
let i=vm.$options._parentVnode
if (isDef(i) && isDef(i=i.data) && isDef(i=i.registerRouteInstance)) {
i(vm, callVal)
}
}
// 為每個組件混入 beforeCreate 鉤子
// 在 `beforeCreate` 鉤子執行時 會初始化路由
Vue.mixin({
beforeCreate () {
// 判斷組件是否存在 router 對象,該對象只在根組件上有
if (isDef(this.$options.router)) {
// 根路由設置為自己
this._routerRoot=this
// this.$options.router就是掛在根組件上的 VueRouter 實例
this._router=this.$options.router
// 執行VueRouter實例上的init方法,初始化路由
this._router.init(this)
// 很重要,為 _route 做了響應式處理
// 即訪問vm._route時會先向dep收集依賴, 而修改_router 會觸發組件渲染
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 用于 router-view 層級判斷
this._routerRoot=(this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
destroyed () {
registerInstance(this)
}
})
/* 在Vue的prototype上面綁定 $router,
這樣可以在任意Vue對象中使用this.$router訪問,同時經過Object.defineProperty,將 $router 代理到 Vue
訪問this.$router 即訪問this._routerRoot._router */
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
/* 同理,訪問this.$route即訪問this._routerRoot._route */
Object.defineProperty(Vue.prototype, '$route', {
get () { return this._routerRoot._route }
})
// 全局注冊組件 router-link 和 router-view
Vue.component('RouterView', View)
Vue.component('RouterLink', Link)
}
2. VueRouter 實例化 在安裝插件后,對 VueRouter 進行實例化。
//用戶定義的路由配置數組
const Home={ template: '<div>home</div>' }
const Foo={ template: '<div>foo</div>' }
const Bar={ template: '<div>bar</div>' }
// 3. Create the router
const router=new VueRouter({
mode: 'hash',
base: __dirname,
routes: [
{ path: '/', component: Home }, // all paths are defined without the hash.
{ path: '/foo', component: Foo },
{ path: '/bar', component: Bar }
]
})
VueRouter 構造函數
src/index.js
// VueRouter 的構造函數
constructor(options: RouterOptions={}) {
// ...
// 路由匹配對象 -- 路由映射表
this.matcher=createMatcher(options.routes || [], this)
// 根據 mode 采取不同的路由方式
let mode=options.mode || 'hash'
this.fallback=mode==='history' && !supportsPushState && options.fallback !==false
if (this.fallback) {
mode='hash'
}
if (!inBrowser) {
mode='abstract'
}
this.mode=mode
switch (mode) {
case 'history':
this.history=new HTML5History(this, options.base)
break
case 'hash':
this.history=new HashHistory(this, options.base, this.fallback)
break
case 'abstract':
this.history=new AbstractHistory(this, options.base)
break
default:
if (process.env.NODE_ENV !=='production') {
assert(false, `invalid mode: ${mode}`)
}
}
}
在實例化 vueRouter 的過程中 通過 createMatcher 創建路由匹配對象(路由映射表),并且根據 mode 來采取不同的路由方式。
src/create-matcher.js
export function createMatcher (
routes: Array<RouteConfig>,
router: VueRouter
): Matcher {
// 創建路由映射表
const { pathList, pathMap, nameMap }=createRouteMap(routes)
function addRoutes (routes) {
createRouteMap(routes, pathList, pathMap, nameMap)
}
// 路由匹配 找到對應的路由
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {
//...
}
return {
match,
addRoutes
}
}
createMatcher 函數的作用就是創建路由映射表,然后通過閉包的方式讓 addRoutes 和 match函數能夠使用路由映射表的幾個對象,最后返回一個 Matcher 對象。 在createMatcher中通過使用 createRouteMap() 根據用戶配置的路由規則來創建對應的路由映射表,返回對應的 pathList, pathMap, nameMap createRouteMap 構造函數 主要用于創建映射表,根據用戶的路由配置規則創建對應的路由映射表 src/create-route-map.js
export function createRouteMap (
routes: Array<RouteConfig>,
oldPathList?: Array<string>,
oldPathMap?: Dictionary<RouteRecord>,
oldNameMap?: Dictionary<RouteRecord>
): {
pathList: Array<string>;
pathMap: Dictionary<RouteRecord>;
nameMap: Dictionary<RouteRecord>;
} {
// 創建映射表
const pathList: Array<string>=oldPathList || []
const pathMap: Dictionary<RouteRecord>=oldPathMap || Object.create(null)
const nameMap: Dictionary<RouteRecord>=oldNameMap || Object.create(null)
// 遍歷路由配置,為每個配置添加路由記錄
routes.forEach(route=> {
addRouteRecord(pathList, pathMap, nameMap, route)
})
// 確保通配符在最后
for (let i=0, l=pathList.length; i < l; i++) {
if (pathList[i]==='*') {
pathList.push(pathList.splice(i, 1)[0])
l--
i--
}
}
return {
pathList,
pathMap,
nameMap
}
}
// 添加路由記錄
function addRouteRecord (
pathList: Array<string>,
pathMap: Dictionary<RouteRecord>,
nameMap: Dictionary<RouteRecord>,
route: RouteConfig,
parent?: RouteRecord,
matchAs?: string
) {
// 獲得路由配置下的屬性
const { path, name }=route
const pathToRegexpOptions: PathToRegexpOptions=route.pathToRegexpOptions || {}
// 格式化 url,替換 /
const normalizedPath=normalizePath(
path,
parent,
pathToRegexpOptions.strict
)
// 生成記錄對象
const record: RouteRecord={
path: normalizedPath,
regex: compileRouteRegex(normalizedPath, pathToRegexpOptions),
components: route.components || { default: route.component },
instances: {},
name,
parent,
matchAs,
redirect: route.redirect,
beforeEnter: route.beforeEnter,
meta: route.meta || {},
props: route.props==null
? {}
: route.components
? route.props
: { default: route.props }
}
if (route.children) {
// 遞歸路由配置的 children 屬性,添加路由記錄
route.children.forEach(child=> {
const childMatchAs=matchAs
? cleanPath(`${matchAs}/${child.path}`)
: undefined
addRouteRecord(pathList, pathMap, nameMap, child, record, childMatchAs)
})
}
// 如果路由有別名的話
// 給別名也添加路由記錄
if (route.alias !==undefined) {
const aliases=Array.isArray(route.alias)
? route.alias
: [route.alias]
aliases.forEach(alias=> {
const aliasRoute={
path: alias,
children: route.children
}
addRouteRecord(
pathList,
pathMap,
nameMap,
aliasRoute,
parent,
record.path || '/' // matchAs
)
})
}
// 更新映射表
if (!pathMap[record.path]) {
pathList.push(record.path)
pathMap[record.path]=record
}
// 命名路由添加記錄
if (name) {
if (!nameMap[name]) {
nameMap[name]=record
} else if (process.env.NODE_ENV !=='production' && !matchAs) {
warn(
false,
`Duplicate named routes definition: ` +
`{ name: "${name}", path: "${record.path}" }`
)
}
}
}
當根組件調用 beforeCreate 鉤子函數時,會執行插件安裝階段注入的 beforeCreate 函數
beforeCreate () {
// 在option上面存在router則代表是根組件
if (isDef(this.$options.router)) {
this._routerRoot=this
this._router=this.$options.router
// 執行_router實例的 init 方法 在 VueRouter 構造函數中的 init()
this._router.init(this)
// 為 vue 實例定義數據劫持 讓 _router 的變化能及時響應頁面的更新
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 非根組件則直接從父組件中獲取
this._routerRoot=(this.$parent && this.$parent._routerRoot) || this
}
// 通過 registerInstance(this, this)這個方法來實現對router-view的掛載操作:主要用于注冊及銷毀實例
registerInstance(this, this)
},
在根組件中進行掛載,非根組件從父級中獲取,保證全局只有一個 路由實例 初始化時執行,保證頁面再刷新時也會進行渲染
init() -- vueRouter 構造函數中的路由初始化
src/index.js
init(app: any /* Vue component instance */) {
// 將當前vm實例保存在app中,保存組件實例
this.apps.push(app)
// 如果根組件已經有了就返回
if (this.app) {
return
}
/* this.app保存當前vm實例 */
this.app=app
// 賦值路由模式
const history=this.history
// 判斷路由模式,以哈希模式為例
if (history instanceof HTML5History) {
// 路由跳轉
history.transitionTo(history.getCurrentLocation())
} else if (history instanceof HashHistory) {
// 添加 hashchange 監聽
const setupHashListener=()=> {
history.setupListeners()
}
// 路由跳轉
history.transitionTo(
history.getCurrentLocation(),
setupHashListener,
setupHashListener
)
}
// 該回調會在 transitionTo 中調用
// 對組件的 _route 屬性進行賦值,觸發組件渲染
history.listen(route=> {
this.apps.forEach(app=> {
app._route=route
})
})
}
init() 核心就是進行路由的跳轉,改變 URL 然后渲染對應的組件。 路由初始化:
5.路由跳轉 transitionTo src/history/base.js
transitionTo (location: RawLocation, onComplete?: Function, onAbort?: Function) {
// 獲取匹配的路由信息
const route=this.router.match(location, this.current)
// 確認切換路由
this.confirmTransition(route, ()=> {
// 以下為切換路由成功或失敗的回調
// 更新路由信息,對組件的 _route 屬性進行賦值,觸發組件渲染
// 調用 afterHooks 中的鉤子函數
this.updateRoute(route)
// 添加 hashchange 監聽
onComplete && onComplete(route)
// 更新 URL
this.ensureURL()
// 只執行一次 ready 回調
if (!this.ready) {
this.ready=true
this.readyCbs.forEach(cb=> { cb(route) })
}
}, err=> {
// 錯誤處理
if (onAbort) {
onAbort(err)
}
if (err && !this.ready) {
this.ready=true
this.readyErrorCbs.forEach(cb=> { cb(err) })
}
})
}
updateRoute (route: Route) {
// 更新當前路由信息 對組件的 _route 屬性進行賦值,觸發組件渲染
const prev=this.current
this.current=route
this.cb && this.cb(route)
// 路由跳轉完成 調用 afterHooks 中的鉤子函數
this.router.afterHooks.forEach(hook=> {
hook && hook(route, prev)
})
}
在路由跳轉前要先匹配路由信息,在確認切換路由后更新路由信息,觸發組件的渲染,最后更新 url
Matcher 中的 match() 在路由配置中匹配到相應的路由則創建對應的路由信息
src/create-matcher.js
function match (
raw: RawLocation,
currentRoute?: Route,
redirectedFrom?: Location
): Route {
// 序列化 url
// 比如對于該 url 來說 /abc?foo=bar&baz=qux#hello
// 會序列化路徑為 /abc
// 哈希為 #hello
// 參數為 foo: 'bar', baz: 'qux'
const location=normalizeLocation(raw, currentRoute, false, router)
const { name }=location
// 如果是命名路由,就判斷記錄中是否有該命名路由配置
if (name) {
const record=nameMap[name]
// 沒找到表示沒有匹配的路由
if (!record) return _createRoute(null, location)
const paramNames=record.regex.keys
.filter(key=> !key.optional)
.map(key=> key.name)
// 參數處理
if (typeof location.params !=='object') {
location.params={}
}
if (currentRoute && typeof currentRoute.params==='object') {
for (const key in currentRoute.params) {
if (!(key in location.params) && paramNames.indexOf(key) > -1) {
location.params[key]=currentRoute.params[key]
}
}
}
if (record) {
location.path=fillParams(record.path, location.params, `named route "${name}"`)
return _createRoute(record, location, redirectedFrom)
}
} else if (location.path) {
// 非命名路由處理
location.params={}
for (let i=0; i < pathList.length; i++) {
// 查找記錄
const path=pathList[i]
const record=pathMap[path]
// 如果匹配路由,則創建路由
if (matchRoute(record.regex, location.path, location.params)) {
return _createRoute(record, location, redirectedFrom)
}
}
}
// 沒有匹配的路由 返回空的路由
return _createRoute(null, location)
}
通過matcher的match方法(有name匹配name,沒有就匹配path,然后返回,默認重新生成一條路由返回) 解析用戶的路由配置并按照route類型返回,然后路由切換就按照這個route來。 根據匹配的條件創建路由 _createRoute() src/create-matcher.js
function _createRoute (
record: ?RouteRecord,
location: Location,
redirectedFrom?: Location
): Route {
// 根據條件創建不同的路由
if (record && record.redirect) {
return redirect(record, redirectedFrom || location)
}
if (record && record.matchAs) {
return alias(record, location, record.matchAs)
}
return createRoute(record, location, redirectedFrom, router)
}
return {
match,
addRoute,
getRoutes,
addRoutes
}
}
createRoute ()
src/util/route.js
export function createRoute (
record: ?RouteRecord,
location: Location,
redirectedFrom?: ?Location,
router?: VueRouter
): Route {
const stringifyQuery=router && router.options.stringifyQuery
let query: any=location.query || {}
try {
// 深拷貝
query=clone(query)
} catch (e) {}
// 創建路由對象
const route: Route={
name: location.name || (record && record.name),
meta: (record && record.meta) || {},
path: location.path || '/',
hash: location.hash || '',
query,
params: location.params || {},
fullPath: getFullPath(location, stringifyQuery),
matched: record ? formatMatch(record) : []
}
if (redirectedFrom) {
route.redirectedFrom=getFullPath(redirectedFrom, stringifyQuery)
}
// 通過Object.freeze定義的只讀對象 route
return Object.freeze(route)
}
// 獲得包含當前路由的所有嵌套路徑片段的路由記錄
// 包含從根路由到當前路由的匹配記錄,從上至下
function formatMatch (record: ?RouteRecord): Array<RouteRecord> {
const res=[]
while (record) {
res.unshift(record)
record=record.parent
}
return res
}
至此匹配路由已經完成,我們回到 transitionTo 函數中,接下來執行 confirmTransition
confirmTransition(route: Route, onComplete: Function, onAbort?: Function) {
const current=this.current
// 中斷跳轉路由函數
const abort=err=> {
if (isError(err)) {
if (this.errorCbs.length) {
this.errorCbs.forEach(cb=> {
cb(err)
})
} else {
warn(false, 'uncaught error during route navigation:')
console.error(err)
}
}
onAbort && onAbort(err)
}
// 如果是相同的路由就不跳轉
if (
isSameRoute(route, current) &&
route.matched.length===current.matched.length
) {
this.ensureURL()
return abort()
}
// 通過對比路由解析出可復用的組件,需要渲染的組件,失活的組件
const { updated, deactivated, activated }=resolveQueue(
this.current.matched,
route.matched
)
function resolveQueue(
current: Array<RouteRecord>,
next: Array<RouteRecord>
): {
updated: Array<RouteRecord>,
activated: Array<RouteRecord>,
deactivated: Array<RouteRecord>
} {
let i
const max=Math.max(current.length, next.length)
for (i=0; i < max; i++) {
// 當前路由路徑和跳轉路由路徑不同時跳出遍歷
if (current[i] !==next[i]) {
break
}
}
return {
// 可復用的組件對應路由
updated: next.slice(0, i),
// 需要渲染的組件對應路由
activated: next.slice(i),
// 失活的組件對應路由
deactivated: current.slice(i)
}
}
// 導航守衛數組
const queue: Array<?NavigationGuard>=[].concat(
// 失活的組件鉤子
extractLeaveGuards(deactivated),
// 全局 beforeEach 鉤子
this.router.beforeHooks,
// 在當前路由改變,但是該組件被復用時調用
extractUpdateHooks(updated),
// 需要渲染組件 enter 守衛鉤子
activated.map(m=> m.beforeEnter),
// 解析異步路由組件
resolveAsyncComponents(activated)
)
// 保存路由
this.pending=route
// 迭代器,用于執行 queue 中的導航守衛鉤子
const iterator=(hook: NavigationGuard, next)=> {
// 路由不相等就不跳轉路由
if (this.pending !==route) {
return abort()
}
try {
// 執行鉤子
hook(route, current, (to: any)=> {
// 只有執行了鉤子函數中的 next,才會繼續執行下一個鉤子函數
// 否則會暫停跳轉
// 以下邏輯是在判斷 next() 中的傳參
if (to===false || isError(to)) {
// next(false)
this.ensureURL(true)
abort(to)
} else if (
typeof to==='string' ||
(typeof to==='object' &&
(typeof to.path==='string' || typeof to.name==='string'))
) {
// next('/') 或者 next({ path: '/' }) -> 重定向
abort()
if (typeof to==='object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
} else {
// 這里執行 next
// 通過 runQueue 中的 step(index+1) 執行 next()
next(to)
}
})
} catch (e) {
abort(e)
}
}
// 經典的同步執行異步函數
runQueue(queue, iterator, ()=> {
const postEnterCbs=[]
const isValid=()=> this.current===route
// 當所有異步組件加載完成后,會執行這里的回調,也就是 runQueue 中的 cb()
// 接下來執行 需要渲染組件中的 beforeRouteEnter 導航守衛鉤子
const enterGuards=extractEnterGuards(activated, postEnterCbs, isValid)
// beforeResolve 解析路由鉤子
const queue=enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, ()=> {
// 跳轉完成
if (this.pending !==route) {
return abort()
}
this.pending=null
onComplete(route)
if (this.router.app) {
this.router.app.$nextTick(()=> {
postEnterCbs.forEach(cb=> {
cb()
})
})
}
})
})
}
export function runQueue (queue: Array<?NavigationGuard>, fn: Function, cb: Function) {
const step=index=> {
// 隊列中的函數都執行完畢,就執行回調函數
if (index >=queue.length) {
cb()
} else {
if (queue[index]) {
// 執行迭代器,用戶在鉤子函數中執行 next() 回調
// 回調中判斷傳參,沒有問題就執行 next(),也就是 fn 函數中的第二個參數
fn(queue[index], ()=> {
step(index + 1)
})
} else {
step(index + 1)
}
}
}
// 取出隊列中第一個鉤子函數
step(0)
}
導航守衛在 確認路由跳轉中出現
const queue: Array<?NavigationGuard>=[].concat(
// 失活的組件鉤子
/*
* 找出組件中對應的鉤子函數, 給每個鉤子函數添加上下文對象為組件自身
* 數組降維,并且判斷是否需要翻轉數組,因為某些鉤子函數需要從子執行到父,
* 獲得鉤子函數數組
*/
extractLeaveGuards(deactivated),
// 全局 beforeEach 鉤子, 將函數 push 進 beforeHooks 中。
this.router.beforeHooks,
// 在當前路由改變,但是該組件被復用時調用
extractUpdateHooks(updated),
// 需要渲染組件 beforeEnter 守衛鉤子
activated.map(m=> m.beforeEnter),
// 解析異步路由組件
resolveAsyncComponents(activated)
)
先執行失活組件 deactivated 的鉤子函數 ,找出對應組件中的鉤子函數
function extractLeaveGuards(deactivated: Array<RouteRecord>): Array<?Function> {
// 傳入需要執行的鉤子函數名 失活組件觸發 beforeRouteLeave
return extractGuards(deactivated, 'beforeRouteLeave', bindGuard, true)
}
function extractUpdateHooks (updated: Array<RouteRecord>): Array<?Function> {
return extractGuards(updated, 'beforeRouteUpdate', bindGuard)
}
function extractGuards(
records: Array<RouteRecord>,
name: string,
bind: Function,
reverse?: boolean
): Array<?Function> {
const guards=flatMapComponents(records, (def, instance, match, key)=> {
// 找出組件中對應的鉤子函數
const guard=extractGuard(def, name)
if (guard) {
// 給每個鉤子函數添加上下文對象為組件自身
return Array.isArray(guard)
? guard.map(guard=> bind(guard, instance, match, key))
: bind(guard, instance, match, key)
}
})
// 數組降維,并且判斷是否需要翻轉數組
// 因為某些鉤子函數需要從子執行到父
return flatten(reverse ? guards.reverse() : guards)
}
export function flatMapComponents (
matched: Array<RouteRecord>,
fn: Function
): Array<?Function> {
// 數組降維
return flatten(matched.map(m=> {
// 將組件中的對象傳入回調函數中,獲得鉤子函數數組
return Object.keys(m.components).map(key=> fn(
m.components[key],
m.instances[key],
m, key
))
}))
}
執行全局 beforeEach 鉤子函數, 將函數 push 進 beforeHooks 中。
beforeEach(fn: Function): Function {
return registerHook(this.beforeHooks, fn)
}
function registerHook(list: Array<any>, fn: Function): Function {
list.push(fn)
return ()=> {
const i=list.indexOf(fn)
if (i > -1) list.splice(i, 1)
}
}
export function resolveAsyncComponents (matched: Array<RouteRecord>): Function {
return (to, from, next)=> {
let hasAsync=false
let pending=0
let error=null
// 扁平化數組 獲取 組件中的鉤子函數數組
flatMapComponents(matched, (def, _, match, key)=> {
// 判斷是否是異步組件
if (typeof def==='function' && def.cid===undefined) {
// 異步組件
hasAsync=true
pending++
// 成功回調
// once 函數確保異步組件只加載一次
const resolve=once(resolvedDef=> {
if (isESModule(resolvedDef)) {
resolvedDef=resolvedDef.default
}
// 判斷是否是構造函數
// 不是的話通過 Vue 來生成組件構造函數
def.resolved=typeof resolvedDef==='function'
? resolvedDef
: _Vue.extend(resolvedDef)
// 賦值組件
// 如果組件全部解析完畢,繼續下一步
match.components[key]=resolvedDef
pending--
if (pending <=0) {
next()
}
})
// 失敗回調
const reject=once(reason=> {
const msg=`Failed to resolve async component ${key}: ${reason}`
process.env.NODE_ENV !=='production' && warn(false, msg)
if (!error) {
error=isError(reason)
? reason
: new Error(msg)
next(error)
}
})
let res
try {
// 執行異步組件函數
res=def(resolve, reject)
} catch (e) {
reject(e)
}
if (res) {
// 下載完成執行回調
if (typeof res.then==='function') {
res.then(resolve, reject)
} else {
const comp=res.component
if (comp && typeof comp.then==='function') {
comp.then(resolve, reject)
}
}
}
}
})
// 不是異步組件直接下一步
if (!hasAsync) next()
}
}
異步組件解析后會執行 runQueue 中的回調函數
// 經典的同步執行異步函數
runQueue(queue, iterator, ()=> {
const postEnterCbs=[] // 存放beforeRouteEnter 中的回調函數
const isValid=()=> this.current===route
// 當所有異步組件加載完成后,會執行這里的回調,也就是 runQueue 中的 cb()
// 接下來執行 需要渲染組件中的 beforeRouteEnter 導航守衛鉤子
const enterGuards=extractEnterGuards(activated, postEnterCbs, isValid)
// beforeResolve 導航守衛鉤子
const queue=enterGuards.concat(this.router.resolveHooks)
runQueue(queue, iterator, ()=> {
// 跳轉完成
if (this.pending !==route) {
return abort()
}
this.pending=null
onComplete(route)
if (this.router.app) {
this.router.app.$nextTick(()=> {
postEnterCbs.forEach(cb=> {
cb()
})
})
}
})
})
但是該鉤子函數在路由確認執行,是唯一一個支持在 next 回調中獲取 this 對象的函數。
// beforeRouteEnter 鉤子函數
function extractEnterGuards (
activated: Array<RouteRecord>,
cbs: Array<Function>,
isValid: ()=> boolean
): Array<?Function> {
return extractGuards(activated, 'beforeRouteEnter', (guard, _, match, key)=> {
return bindEnterGuard(guard, match, key, cbs, isValid)
})
}
function bindEnterGuard (
guard: NavigationGuard,
match: RouteRecord,
key: string,
cbs: Array<Function>,
isValid: ()=> boolean
): NavigationGuard {
return function routeEnterGuard (to, from, next) {
return guard(to, from, cb=> {
next(cb)
if (typeof cb==='function') {
// 判斷 cb 是否是函數
// 是的話就 push 進 postEnterCbs
cbs.push(()=> {
// #750
// if a router-view is wrapped with an out-in transition,
// the instance may not have been registered at this time.
// we will need to poll for registration until current route
// is no longer valid.
// 循環直到拿到組件實例
poll(cb, match.instances, key, isValid)
})
}
})
}
}
// 該函數是為了解決 issus #750
// 當 router-view 外面包裹了 mode 為 out-in 的 transition 組件
// 會在組件初次導航到時獲得不到組件實例對象
function poll (
cb: any, // somehow flow cannot infer this is a function
instances: Object,
key: string,
isValid: ()=> boolean
) {
if (instances[key]) {
cb(instances[key])
} else if (isValid()) {
// setTimeout 16ms 作用和 nextTick 基本相同
setTimeout(()=> {
poll(cb, instances, key, isValid)
}, 16)
}
}
updateRoute (route: Route) {
// 更新當前路由信息 對組件的 _route 屬性進行賦值,觸發組件渲染
const prev=this.current
this.current=route
this.cb && this.cb(route) // 實際執行 init傳入的回調, app._route=route 對組件的 _route 屬性進行賦值
// 路由跳轉完成 調用 afterHooks 中的鉤子函數
this.router.afterHooks.forEach(hook=> {
hook && hook(route, prev)
})
}
this.cb 是怎么來的呢? 其實 this.cb 是通過 History.listen 實現的,在VueRouter 的初始化 init 過程中對 this.cb 進行了賦值
// History 類中 的listen 方法對this.cb 進行賦值
listen (cb: Function) {
this.cb=cb
}
// init 中執行了 history.listen,將回調函數賦值給 this.cb
init (app: any /* Vue component instance */) {
this.apps.push(app)
history.listen(route=> {
this.apps.forEach((app)=> {
app._route=route
})
})
}
當app._router 發生變化時觸發 vue 的響應式調用render() 將路由相應的組件渲染到中
app._route=route
hash模式的原理是監聽瀏覽器url中hash值的變化,并切換對應的組件
class HashHistory extends History {
constructor (router: Router, base: ?string, fallback: boolean) {
super(router, base)
// check history fallback deeplinking
if (fallback && checkFallback(this.base)) {
return
}
ensureSlash()
}
// 監聽 hash 的變化
setupListeners () {
const router=this.router
const expectScroll=router.options.scrollBehavior
const supportsScroll=supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', ()=> {
const current=this.current
if (!ensureSlash()) {
return
}
// 傳入當前的 hash 并觸發跳轉
this.transitionTo(getHash(), route=> {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
})
}
}
// 如果瀏覽器沒有 # 則自動補充 /#/
function ensureSlash (): boolean {
const path=getHash()
if (path.charAt(0)==='/') {
return true
}
replaceHash('/' + path)
return false
}
export default HashHistory
如果手動刷新頁面的話,是不會觸發hashchange事件的,也就是找不出組件來,那咋辦呢?刷新頁面肯定會使路由重新初始化,咱們只需要在初始化函數init 上執行一次原地跳轉就行。 router-view 組件渲染 組件渲染的關鍵在于 router-view ,將路由變化時匹配到的組件進行渲染。 routerView是一個函數式組件,函數式組件沒有data,沒有組件實例。 因此使用了父組件中的$createElement函數,用以渲染組件,并且在組件渲染的各個時期注冊了hook 如果被 keep-alive 包裹則直接使用緩存的 vnode 通過 depth 實現路由嵌套, 循環向上級訪問,直到訪問到根組件,得到路由的 depth 深度
export default {
name: 'RouterView',
/*
https://cn.vuejs.org/v2/api/#functional
使組件無狀態 (沒有 data ) 和無實例 (沒有 this 上下文)。他們用一個簡單的 render 函數返回虛擬節點使他們更容易渲染。
*/
functional: true,
props: {
name: {
type: String,
default: 'default'
}
},
render (_, { props, children, parent, data }) {
/* 標記位,標記是route-view組件 */
data.routerView=true
/* 直接使用父組件的createElement函數 因此router-view渲染的組件可以解析命名槽*/
const h=parent.$createElement
/* props的name,默認'default' */
const name=props.name
/* option中的VueRouter對象 */
const route=parent.$route
/* 在parent上建立一個緩存對象 */
const cache=parent._routerViewCache || (parent._routerViewCache={})
/* 記錄組件深度 用于實現路由嵌套 */
let depth=0
/* 標記是否是待用(非alive狀態)) */
let inactive=false
/* _routerRoot中中存放了根組件的勢力,這邊循環向上級訪問,直到訪問到根組件,得到depth深度 */
// 用 depth 幫助找到對應的 RouterRecord
while (parent && parent._routerRoot !==parent) {
if (parent.$vnode && parent.$vnode.data.routerView) {
// 遇到其他的 router-view 組件則路由深度+1
depth++
}
/* 如果_inactive為true,代表是在keep-alive中且是待用(非alive狀態) */
if (parent._inactive) {
inactive=true
}
parent=parent.$parent
}
/* 存放route-view組件的深度 */
data.routerViewDepth=depth
/* 如果inactive為true說明在keep-alive組件中,直接從緩存中取 */
if (inactive) {
return h(cache[name], data, children)
}
// depth 幫助 route.matched 找到對應的路由記錄
const matched=route.matched[depth]
/* 如果沒有匹配到的路由,則渲染一個空節點 */
if (!matched) {
cache[name]=null
return h()
}
/* 從成功匹配到的路由中取出組件 */
const component=cache[name]=matched.components[name]
// attach instance registration hook
// this will be called in the instance's injected lifecycle hooks
/* 注冊實例的registration鉤子,這個函數將在實例被注入的加入到組件的生命鉤子(beforeCreate與destroyed)中被調用 */
data.registerRouteInstance=(vm, val)=> {
/* 第二個值不存在的時候為注銷 */
// val could be undefined for unregistration
/* 獲取組件實例 */
const current=matched.instances[name]
if (
(val && current !==vm) ||
(!val && current===vm)
) {
/* 這里有兩種情況,一種是val存在,則用val替換當前組件實例,另一種則是val不存在,則直接將val(這個時候其實是一個undefined)賦給instances */
matched.instances[name]=val
}
}
// also register instance in prepatch hook
// in case the same component instance is reused across different routes
;(data.hook || (data.hook={})).prepatch=(_, vnode)=> {
matched.instances[name]=vnode.componentInstance
}
// resolve props
let propsToPass=data.props=resolveProps(route, matched.props && matched.props[name])
if (propsToPass) {
// clone to prevent mutation
propsToPass=data.props=extend({}, propsToPass)
// pass non-declared props as attrs
const attrs=data.attrs=data.attrs || {}
for (const key in propsToPass) {
if (!component.props || !(key in component.props)) {
attrs[key]=propsToPass[key]
delete propsToPass[key]
}
}
}
return h(component, data, children)
}
}
嵌套路由的實現 routerView的render函數通過定義一個depth參數,來判斷當前嵌套的路由是位于matched函數層級,然后取出對應的record對象,渲染器對應的組件。 router-link 組件 router-link 的本質是 a 標簽,在標簽上綁定了click事件,然后執行對應的VueRouter實例的push()實現的
export default {
name: 'RouterLink',
props: {
to: {
type: toTypes,
required: true
},
tag: {
type: String,
default: 'a'
},
exact: Boolean,
append: Boolean,
replace: Boolean, // 當點擊時會調用router.replace()而不是router.push(),這樣導航后不會留下history記錄
activeClass: String,
exactActiveClass: String,
event: {
type: eventTypes,
default: 'click' // 默認為 click 事件
}
},
render (h: Function) {
// 獲取 $router 實例
const router=this.$router
// 獲取當前路由對象
const current=this.$route
// 要跳轉的地址
const { location, route, href }=router.resolve(this.to, current, this.append)
const classes={}
const globalActiveClass=router.options.linkActiveClass
const globalExactActiveClass=router.options.linkExactActiveClass
// Support global empty active class
const activeClassFallback=globalActiveClass==null
? 'router-link-active'
: globalActiveClass
const exactActiveClassFallback=globalExactActiveClass==null
? 'router-link-exact-active'
: globalExactActiveClass
const activeClass=this.activeClass==null
? activeClassFallback
: this.activeClass
const exactActiveClass=this.exactActiveClass==null
? exactActiveClassFallback
: this.exactActiveClass
const compareTarget=location.path
? createRoute(null, location, null, router)
: route
classes[exactActiveClass]=isSameRoute(current, compareTarget)
classes[activeClass]=this.exact
? classes[exactActiveClass]
: isIncludedRoute(current, compareTarget)
const handler=e=> {
// 綁定點擊事件
// 若設置了 replace 屬性則使用 router.replace 切換路由
// 否則使用 router.push 更新路由
if (guardEvent(e)) {
if (this.replace) {
// router.replace() 導航后不會留下history記錄
router.replace(location)
} else {
router.push(location)
}
}
}
const on={ click: guardEvent } // <router-link> 組件默認都支持的click事件
if (Array.isArray(this.event)) {
this.event.forEach(e=> { on[e]=handler })
} else {
on[this.event]=handler
}
const data: any={
class: classes
}
if (this.tag==='a') { // 如果是 a 標簽會綁定監聽事件
data.on=on // 監聽自身
data.attrs={ href }
} else {
// find the first <a> child and apply listener and href
const a=findAnchor(this.$slots.default) // 如果不是 a標簽則會 找到第一個 a 標簽
if (a) {
// in case the <a> is a static node // 找到第一個 a 標簽
a.isStatic=false
const extend=_Vue.util.extend
const aData=a.data=extend({}, a.data)
aData.on=on
const aAttrs=a.data.attrs=extend({}, a.data.attrs)
aAttrs.href=href
} else {
// doesn't have <a> child, apply listener to self
data.on=on // 如果沒找到 a 標簽就監聽自身
}
}
//最后調用$createElement去創建該Vnode
return h(this.tag, data, this.$slots.default)
}
}
// 阻止瀏覽器的默認事件,所有的事件都是通過 VueRouter 內置代碼實現的
function guardEvent (e) {
// don't redirect with control keys
if (e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) return
// don't redirect when preventDefault called
if (e.defaultPrevented) return
// don't redirect on right click
if (e.button !==undefined && e.button !==0) return
// don't redirect if `target="_blank"`
if (e.currentTarget && e.currentTarget.getAttribute) {
const target=e.currentTarget.getAttribute('target')
if (/\b_blank\b/i.test(target)) return
}
// this may be a Weex event which doesn't have this method
if (e.preventDefault) {
e.preventDefault()
}
return true
}
在混入 beforeCreate 時 對 _route 作了響應式處理,即訪問vm._route時會先向dep收集依賴
beforeCreate () {
// 判斷組件是否存在 router 對象,該對象只在根組件上有
if (isDef(this.$options.router)) {
// 根路由設置為自己
this._routerRoot=this
// this.$options.router就是掛在根組件上的 VueRouter 實例
this._router=this.$options.router
// 執行VueRouter實例上的init方法,初始化路由
this._router.init(this)
// 很重要,為 _route 做了響應式處理
// 即訪問vm._route時會先向dep收集依賴, 而修改 _router 會觸發組件渲染
Vue.util.defineReactive(this, '_route', this._router.history.current)
} else {
// 用于 router-view 層級判斷
this._routerRoot=(this.$parent && this.$parent._routerRoot) || this
}
registerInstance(this, this)
},
// 訪問vm._route時會先向dep收集依賴
Object.defineProperty(Vue.prototype, '$router', {
get () { return this._routerRoot._router }
})
訪問 $router 時觸發依賴收集
何時觸發 dep.notify 呢? 路由導航實際執行的history.push方法 會觸發 tansitionTo
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute }=this
this.transitionTo(location, route=> {
pushState(cleanPath(this.base + route.fullPath))
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}
在確認路由后執行回調時會通過 updateRoute 觸發 this.$route 的修改
updateRoute (route: Route) {
// 更新當前路由信息 對組件的 _route 屬性進行賦值,觸發組件渲染
const prev=this.current
this.current=route
this.cb && this.cb(route)
this.router.afterHooks.forEach(hook=> {
hook && hook(route, prev)
})
}
其中 this.cb 在路由初始化過程中 通過history.listen 保存的
// VueRouter 路由初始化時設置的 listen 回調
history.listen(route=> {
this.apps.forEach((app)=> {
// $router 的更新==>> app._route=route則觸發了set,即觸發dep.notify向watcher派發更新
app._route=route
})
})
// history 類中 cb的取值
listen (cb: Function) {
this.cb=cb
}
當組件重新渲染, vue 通過 router-view 渲染到指定位置 綜上所述 路由觸發組件更新依舊是沿用的vue組件的響應式核心, 在執行transitionTo 前手動觸發依賴收集, 在路由transitionTo 過程中手動觸發更新派發以達到watcher的重新update; 而之所以路由能正確的顯示對應的組件,則得益于路由映射表中保存的路由樹形關系 $router.push 切換路由的過程 vue-router 通過 vue.mixin 方法注入 beforeCreate 鉤子,該混合在 beforeCreate 鉤子中通過 Vue.util.defineReactive() 定義了響應式的 _route 。所謂響應式屬性,即當 _route 值改變時,會自動調用 Vue 實例的 render() 方法,更新視圖。 vm.render()是根據當前的_route 的 path,nam 等屬性,來將路由對應的組件渲染到 router-view 中
History.replace() 在 hash 模式下
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute }=this
this.transitionTo(location, route=> {
replaceHash(route.fullPath)
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}
function replaceHash (path) {
if (supportsPushState) {
replaceState(getUrl(path))
} else {
window.location.replace(getUrl(path))
}
}
通過 window.location.replace 替換當前路由,這樣不會將新路由添加到瀏覽器訪問歷史的棧頂,而是替換掉當前的路由。
history模式下
replace (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute }=this
this.transitionTo(location, route=> {
replaceState(cleanPath(this.base + route.fullPath))
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}
在地址欄修改 url 時 vueRouter 會發生什么變化
當路由采用 hash 模式時,監聽了瀏覽器 hashChange 事件,在路由發生變化后調用 replaceHash()
// 監聽 hash 的變化
setupListeners () {
const router=this.router
const expectScroll=router.options.scrollBehavior
const supportsScroll=supportsPushState && expectScroll
if (supportsScroll) {
setupScroll()
}
window.addEventListener(supportsPushState ? 'popstate' : 'hashchange', ()=> {
const current=this.current
if (!ensureSlash()) {
return
}
// 傳入當前的 hash 并觸發跳轉
this.transitionTo(getHash(), route=> {
if (supportsScroll) {
handleScroll(this.router, route, current, true)
}
if (!supportsPushState) {
replaceHash(route.fullPath)
}
})
})
}
在路由初始化的時候會添加事件 setupHashListener 來監聽 hashchange 或 popstate;當路由變化時,會觸發對應的 push 或 replace 方法,然后調用 transitionTo 方法里面的 updateRoute 方法來更新 _route,從而觸發 router-view 的變化。 所以在瀏覽器地址欄中直接輸入路由相當于代碼調用了replace()方法,將路由替換成輸入的 url。 在 history 模式下的路由監聽是在構造函數中執行的,對 HTML5History 的 popstate 事件進行監聽
window.addEventListener('popstate', e=> {
const current=this.current
const location=getLocation(this.base)
if (this.current===START && location===initLocation) {
return
}
this.transitionTo(location, route=> {
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
})
小結 頁面渲染 1、Vue.use(Router) 注冊 2、注冊時調用 install 方法混入生命周期,定義 router 和 route 屬性,注冊 router-view 和 router-link 組件 3、生成 router 實例,根據配置數組(傳入的routes)生成路由配置記錄表,根據不同模式生成監控路由變化的History對象 4、生成 vue 實例,將 router 實例掛載到 vue 實例上面,掛載的時候 router 會執行最開始混入的生命周期函數 5、初始化結束,顯示默認頁面 路由點擊更新 1、 router-link 綁定 click 方法,觸發 history.push 或 history.replace ,從而觸發 history.transitionTo 方法 2、ransitionTo 用于處理路由轉換,其中包含了 updateRoute 用于更新 _route 3、在 beforeCreate 中有劫持 _route 的方法,當 _route 變化后,觸發 router-view 的變化 地址變化路由更新 1、HashHistory 和 HTML5History 會分別監控 hashchange 和 popstate 來對路由變化作對用的處理 2、HashHistory 和 HTML5History 捕獲到變化后會對應執行 push 或 replace 方法,從而調用 transitionTo 3、然后更新 _route 觸發 router-view 的變化 路由相關問題 1. vue-router響應路由參數的變化
// 監聽當前路由發生變化的時候執行
watch: {
$route(to, from){
console.log(to.path)
// 對路由變化做出響應
}
}
在組件被復用的情況下,在同一組件中路由動態傳參的變化 如: 動態參數的路徑 /foo/:id,在 /foo/1 和 /foo/2 之間跳轉的時候,
beforeRouteUpdate(to, from, next){
// to do somethings
}
在每次組件渲染時執行 beforeRouterEnter
beforeRouteEnter(to, from, next){
next(vm=>{
console.log(vm)
// 每次進入路由執行
vm.getData() // 獲取數據
})
},
在 keep-alive 組件被激活時都會執行 actived 鉤子
服務器端渲染期間 avtived 不被調用
activated(){
this.getData() // 獲取數據
},
總結 當時在寫這篇文的時候就是想著盡量能把各個知識點都串聯上,建立完善的知識體系 這不寫著寫著就成了長文, 一旦開始就無法停下,那就硬著頭皮繼續吧 不過這篇長文真的是有夠長的,哈哈哈哈,能堅持看到這里的同學我都感到佩服 如果覺得還有哪里缺失的點可以及時告訴我哦 那么今天就先到這啦
*請認真填寫需求信息,我們會在24小時內與您取得聯系。