在日常开发中,我们经常会有格式化的需求,如日期格式化、数字格式化、钱币格式化等等。
格式化器的作用似乎跟转换器的作用类似,但是它们的关注点却不一样:
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小时内与您取得联系。