整合营销服务商

电脑端+手机端+微信端=数据同步管理

免费咨询热线:

字符串拼接一定得用MessageFormat#for

字符串拼接一定得用MessageFormat#format?

DK拍了拍你:字符串拼接一定记得用MessageFormat#format !

在日常开发中,我们经常会有格式化的需求,如日期格式化、数字格式化、钱币格式化等等。

格式化器的作用似乎跟转换器的作用类似,但是它们的关注点却不一样:

  • 转换器:将类型S转换为类型T,关注的是类型而非格式
  • 格式化器: String <-> Java类型。这么一看它似乎和PropertyEditor类似,但是它的关注点是字符串的格式

Spring有自己的格式化器抽象org.springframework.format.Formatter,但是谈到格式化器,必然就会联想起来JDK自己的java.text.Format体系。为后文做好铺垫,本文就先介绍下JDK为我们提供了哪些格式化能力。

版本约定

  • JDK:8

?正文

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);
}
  • format:将Object格式化为String,并将此String放到toAppendTo里面
  • parseObject:讲String转换为Object,是format方法的逆向操作

Java SE针对于Format抽象类对于常见的应用场景分别提供了三个子类实现:

DateFormat:日期时间格式化

抽象类。用于用于格式化日期/时间类型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

一般来说,我们会直接使用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。同时我个人建议:在项目中可强制严令禁用

NumberFormat:数字格式化

抽象类。用于格式化数字,它可以对数字进行任意格式化,如小数、百分数、十进制数等等。它有两个实现类:

类结构和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的主力了。

DecimalFormat

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组成的

看了这个表格的符号规则,估计很多同学还是一脸懵逼。不啰嗦了,上干货

一、0和#的使用(最常见使用场景)

这是最经典、最常见的使用场景,甚至来说你有可能职业生涯会用到此场景。

/**
 * {@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

通过此案例,大致可得出如下结论:

  • 整数部分:0和#都可用于取出全部整数部分0的个数决定整数部分长度,不够高位补0;#则无此约束,N多个#是一样的效果
  • 小数部分:可保留小数点后N位(0和#效果一样)若小数点后位数不够,若使用的0那就低位补0,若使用#就不补(该是几位就是几位)
  • 数字(1-9):并不建议模版里直接写1-9这样的数字,了解下即可

二、科学计数法E

如果你不是在证券/银行行业,这个大概率是用不着的(即使在,你估计也不会用它)。来几个例子感受一把就成:

@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。

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));
}

运行程序,输出:

周一
周四
周五
周天
周天

结果解释:

  1. 4.3位于4和5之间,取值4;5.8位于5和6之间,取值5
  2. 9.1和11均超过了数组最大值(或者说找不到匹配的),则取值最后一对键值对

可能你会想这有什么使用场景???是的,不得不承认它的使用场景较少,本文下面会介绍下它和MessageFormat的一个使用场景。

如果说DateFormatNumberFormat都用没什么花样,主要记住它的pattern语法格式就成,那么就下来这个格式化器就是本文的主菜了,使用场景非常的广泛,它就是MessageFormat

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]] 
  • ArgumentIndex非必须。从0开始的索引值
  • FormatType非必须。使用不同的java.text.Format实现类对入参进行格式化处理。它能有如下值:number:调用NumberFormat进行格式化date:调用DateFormat进行格式化time:调用DateFormat进行格式化choice:调用ChoiceFormat进行格式化
  • FormatStyle非必须。设置FormatType使用的样式。它能有如下值:short、medium、long、full、integer、currency、percent、SubformPattern(如日期格式、数字格式#.##等)

说明: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.
}
  1. 参数模式的索引值必须从0开始,否则所有索引值无效
  2. 实际传入的参数个数可以和索引个数不匹配,不报错(能匹配上几个算几个)
  3. 两个单引号''才算作一个',若只写一个将被忽略甚至影响整个表达式谨慎使用单引号'关注'的匹配关系
  4. {}只写左边报错,只写右边正常输出(注意参数的对应关系)

static方法的性能问题

我们知道MessageFormat提供有一个static静态方法,非常方便地的使用:

public static String format(String pattern, Object ... arguments) {
    MessageFormat temp=new MessageFormat(pattern);
    return temp.format(arguments);
}

可以清晰看到,该静态方法本质上还是构造了一个MessageFormat实例去做格式化的。因此:若你要多次(如高并发场景)格式化同一个模版(参数可不一样)的话,那么提前创建好一个全局的(非static) MessageFormat实例再执行格式化是最好的,而非一直调用其静态方法。

说明:若你的系统非高并发场景,此性能损耗基本无需考虑哈,怎么方便怎么来。毕竟朝生夕死的对象对JVM来说没啥压力

和String.format选谁?

二者都能用于字符串拼接(格式化)上,撇开MessageFormat支持各种模式不说,我们只需要考虑它俩的性能上差异。

  • MeesageFormat:先分析(模版可提前分析,且可以只分析一次),再在指定位置上插入相应的值分析:遍历字符串,维护一个{}数组并记录位置填值
  • String.format:该静态方法是采用运行时用正则表达式 匹配到占位符,然后执行替换的正则表达式为"%(\d+\$)?([-#+ 0,(\<]*)?(\d+)?(\.\d+)?([tT])?([a-zA-Z%])"根据正则匹配到占位符列表和位置,然后填值

一说到正则表达式,我心里就发怵,因为它对性能是不友好的,所以孰优孰劣,高下立判。

说明:还是那句话,没有绝对的谁好谁坏,如果你的系统对性能不敏感,那就是方便第一

经典使用场景

这个就很多啦,最常见的有: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原生的格式化器知识点,主要作用在这三个方面:

  • DateFormat:日期时间格式化
  • NumberFormat:数字格式化
  • MessageFormat:字符串格式化

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),
}

简单对上面的代码做一下说明:

  1. 先说返回值,由于我们解析完word之后,需要将xml文件读取出来,根据语义再转成html,因此我们需要整个document.xml中的内容,因此返回documentXML,而且还要知道列表的渲染机制,因此也需要返回numberingXML,同样我们需要获取到文档中用了哪些图片,以及它们的位置,所以要返回relsXML,并且我要把对应的图片放到另一个地方存储起来以供使用,所以也要返回imagePath,最后整个文档的样式,也就是styleXML也要返回。
  2. 第1行就是获取到上传的word路径,这里是我自己做了一个上传方法。
  3. 第2行通过adm-zip插件对文件进行解压和读取。
  4. 第3行就是指定获取document.xml文件的内容。
  5. 第4行就是用xml-js对读取到的内容进行解析,之后的代码同理,只是去解析不同的文件而已。
  6. 第13行读取该压缩文件中的目录结构。
  7. 第16行至第22行就是找出word里面用到的所有图片,并将它们存储在其他位置。

至此,我们看一下目前解析完成之后,形成的数据结构。

示意图

很好,现在开始集成:

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)

  • 把前端路由的路径用井号 # 拼接在真实 url 后面的模式,但是会覆盖锚点定位元素的功能,通过监听 URL 的哈希部分变化,相应地更新页面的内容。
  • 前端路由的处理完全在客户端进行,在路由发生变化时,只会改变 URL 中的哈希部分(井号 # 后面的路径),且不会向服务器发送新的请求,而是触发 onhashchange 事件。
  • hash 只有#符号之前的内容才会包含在请求中被发送到后端,如果 nginx 没有匹配得到当前的 url 也没关系。hash 永远不会提交到 server 端。
  • hash值的改变,都会在浏览器的访问历史中增加一个记录,所以可以通过浏览器的回退、前进按钮控制hash的切换。
  • hash 路由不会造成 404 页面的问题,因为所有路由信息都在客户端进行解析和处理,服务器只负责提供应用的初始 HTML 页面和静态资源,不需要关心路由的匹配问题。
// onhashchage事件,可以在window对象上监听这个事件
window.onhashchange=function(event){
  console.log(event.oldURL, event.newURL)
  let hash=location.hash.slice(1)
}


  • 通过location.hash修改hash值,触发更新。
  • 通过监听hashchange事件监听浏览器前进或者后退,触发更新。

history模式 (对应HTML5History)

  • 是 html5 新推出的功能,比 Hash url 更美观
  • 在 history 模式下浏览器在刷新页面时,会按照路径发送真实的资源请求。如果 nginx 没有匹配得到当前的 url ,就会出现 404 的页面。
  • 在使用 history 模式时,需要通过服务端支持允许地址可访问,如果没有设置,就很容易导致出现 404 的局面。
  • 改变url: history 提供了 pushState 和 replaceState 两个方法来记录路由状态,这两个方法只改变 URL 不会引起页面刷新。
  • 监听url变化:通过 onpopstate 事件监听history变化,在点击浏览器的前进或者后退功能时触发,在onpopstate 事件中根据状态信息加载对应的页面内容。
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 构建单页应用变得轻而易举。功能包括:

  • 路由映射:可以将 url 映射到 Vue组件,实现不同 url 对应不同的页面内容。
  • 嵌套路由映射:可以在路由下定义子路由,实现更复杂的页面结构和嵌套组件的渲染。
  • 动态路由:通过路由参数传递数据。你可以在路由配置中定义带有参数的路由路径,并通过 $route.params 获取传递的参数。
  • 模块化、基于组件的路由配置:路由配置是基于组件的,每个路由都可以指定一个 Vue 组件作为其页面内容,将路由配置拆分为多个模块,在需要的地方引入。。
  • 路由参数、查询、通配符:通过路由参数传递数据,实现页面间的数据传递和动态展示。
  • 导航守卫:Vue Router 提供了全局的导航守卫和路由级别的导航守卫,可以在路由跳转前后执行一些操作,如验证用户权限、加载数据等。
  • 展示由 Vue.js 的过渡系统提供的过渡效果:可以为路由组件添加过渡效果,使页面切换更加平滑和有动感。
  • 细致的导航控制:可以通过编程式导航(通过 JavaScript 控制路由跳转)和声明式导航(通过 组件实现跳转)实现页面的跳转。
  • 路由模式设置:Vue Router 支持两种路由模式:HTML5 history 模式或 hash 模式
  • 可定制的滚动行为:当页面切换时,Vue Router 可以自动处理滚动位置。定制滚动行为,例如滚动到页面顶部或指定的元素位置。
  • URL 的正确编码:Vue Router 会自动对 URL 进行正确的编码

路由组件

  • **router-link:**通过 router-link 创建链接 其本质是a标签,这使得 Vue Router 可以在不重新加载页面的情况下更改 URL,处理 URL 的生成以及编码。
  • **router-view:**router-view 将显示与 url 对应的组件。

$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')
		}
  ]
})

vueRouter的使用

页面中路由展示位置

<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) 来达到这个效果。

  • 动态路由的创建,主要是使用 path 属性过程中,使用动态路径参数,路径参数 用冒号 : 表示。

当一个路由被匹配时,它的 params 的值将在每个组件中以 this.$route.query 的形式暴露出来。因此,我们可以通过更新 User 的模板来呈现当前的用户 ID:

const routes=[
  {
    path: '/user/:id'
    name: 'User'
    components: User
	}
]

_vue-router _通过配置 _params __query _来实现动态路由

params 传参

  • 必须使用 命名路由 name 传值
  • 参数不会显示在 url 上
  • 浏览器强制刷新时传参会被清空
// 传递参数
this.$router.push({
  name: Home,
  params: {
    number: 1 ,
    code: '999'
  }
})
// 接收参数
const p=this.$route.params

query 传参

  • 可以用 name 也可以使用 path 传参
  • 传递的参数会显示在 url 上
  • 页面刷新是传参不会丢失
// 方式一:路由拼接
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属性:

  • include - 字符串或正则表达式。只有名称匹配的组件会被缓存
  • exclude - 字符串或正则表达式。任何名称匹配的组件都不会被缓存
  • max - 数字。最多可以缓存多少组件实例

在不缓存组件实例的情况下,每次切换都会重新 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

在路由中设置 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

  • 首次进入组件时也会触发 actived 钩子函数:beforeRouteEnter > beforeCreate > created> beforeMount > beforeRouteEnter 的 next 回调> mounted > activated > ... ... > beforeRouteLeave > deactivated
  • 再次进入组件时直接获取actived的组件内容:beforeRouteEnter >activated > ... ... > beforeRouteLeave > deactivated

keep-alive 组件监听 include 及 exclude 的缓存规则,若发生变化则执行 pruneCache (遍历cache 的name判断是否需要缓存,否则将其剔除) 且 keep-alive 中没有 template,而是用了 render,在组件渲染的时候会自动执行 render 函数,

  • 若命中缓存则直接从缓存中拿 vnode 的组件实例,
  • 若未命中缓存且未被缓存过则将该组件存入缓存,
  • 当缓存数量超出最大缓存数量时,删除缓存中的第一个组件。

动态路由缓存的的具体表现在:

  • 由动态路由配置的路由只能缓存一份数据。
  • keep-alive 动态路由只有第一个会有完整的生命周期,之后的路由只会触发 actived 和 deactivated这两个钩子。
  • 一旦更改动态路由的某个路由数据,期所有同路由下的动态路由数据都会同步更新。

如何删除 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>

路由守卫 导航守卫主要用来通过跳转或取消的方式守卫导航。有多种机会植入路由导航过程中:全局的, 单个路由独享的, 或者组件级的。 通俗来讲:路由守卫就是路由跳转过程中的一些生命周期函数(钩子函数),我们可以利用这些钩子函数帮我们实现一些需求。 路由守卫又具体分为 全局路由守卫独享守卫组件路由守卫。 全局路由守卫

  • 全局前置守卫router.beforeEach
  • 全局解析守卫:router.beforeResolve
  • 全局后置守卫:router.afterEach

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
      }
    }
  }
})

afterEach(to,from)

和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],
  },
]

组件路由守卫 在组件内使用的钩子函数,类似于组件的生命周期, 钩子函数执行的顺序包括

  • beforeRouteEnter(to,from, next) -- 进入前
  • beforeRouteUpdate(to,from, next) -- 路由变化时
  • beforeRouteLeave(to,from, next) -- 离开后

组件内路由守卫的执行时机:


<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 字(可选)


  1. 触发全局的路由守卫 beforeEach
  2. 组件在路由配置的独享路由 beforeEnter
  3. 进入组件中的 beforeRouteEnter,此时无法获取组件对象
  4. 触发全局解析守卫 beforeResolve
  5. 此时路由完成跳转 触发全局后置守卫 afterEach
  6. 组件的挂载 beforeCreate --> created --> beforeMount
  7. 路由守卫 beforeRouterEnter 中的 next回调, 此时能够获取到组件实例 vm
  8. 完成组件的挂载 mounted

当点击切换路由时: A页面跳转至B页面触发的生命周期及路由守卫顺序:

添加图片注释,不超过 140 字(可选)


  1. 导航被触发进入其他路由。
  2. 在离开的路由组件中调用 beforeRouteLeave 。
  3. 调用全局的前置路由守卫 beforeEach 。
  4. 在重用的组件里调用 beforeRouteUpdate 守卫。
  5. 调用被激活组件的路由配置中调用 beforeEnter。
  6. 解析异步路由组件。
  7. 在被激活的组件中调用 beforeRouteEnter。
  8. 调用全局的 beforeResolve 守卫。
  9. 导航被确认。
  10. 调用全局后置路由 afterEach 钩子。
  11. 触发 DOM 更新,激活组件的创建及挂载 beforeCreate (新)-->created (新)-->beforeMount(新) 。
  12. 调用 beforeRouteEnter 守卫中传给 next 的回调函数,创建好的组件实例会作为回调函数的参数传入。
  13. 失活组件的销毁 beforeDestory(旧)-->destoryed(旧)
  14. 激活组件的挂载 mounted(新)

路由守卫的触发顺序 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)
}


  1. 使用 Vue.mixin 为每个组件混入 beforeCreate 钩子,全局混入添加组件选项 挂载 router 配置项
  2. 通过 defineReactive 为vue实例实现数据劫持 让_router能够及时响应页面更新
  3. 将 router、router 、router、route 代理到 Vue 原型上
  4. 全局注册 router-view 及 router-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 来采取不同的路由方式。

3.创建路由匹配对象

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 函数的作用就是创建路由映射表,然后通过闭包的方式让 addRoutesmatch函数能够使用路由映射表的几个对象,最后返回一个 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}" }`
      )
    }
  }
}

4.路由初始化 init

当根组件调用 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 然后渲染对应的组件。 路由初始化:

  1. 在Vue调用init进行初始化时会调用beforeCreate钩子函数
  2. init方法中调用了transationTo 路由跳转
  3. 在transationTo方法中又调用了confirmTransation 确认跳转路由,最终在这里执行了runQueue方法,
  4. runQueue 会把队列 queue 中的所有函数调用执行,其中就包括 路由守卫钩子函数 的执行

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
}

6. 确认跳转

至此匹配路由已经完成,我们回到 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)
}

7. 导航守卫

导航守卫在 确认路由跳转中出现

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)
  }
}
  1. 执行 beforeRouteUpdate 钩子函数 与 deactivated 实现类似
  2. 执行 beforeEnter 独享路由钩子
  3. 解析异步组件
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()
          })
        })
      }
    })
  })
  1. 执行 beforeRouterEnter ,因为在 beforeRouterEnter 在路由确认之前组件还未渲染,所以此时无法访问到组件的 this 。

但是该钩子函数在路由确认执行,是唯一一个支持在 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)
  }
}
  1. 执行 beforeResolve 导航守卫钩子,如果注册了全局 beforeResolve 钩子就会在这里执行。
  2. 导航确认完成后 updateRoute 切换路由,更新路由信息后 调用 afterEach 导航守卫钩子
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
    })
  })
}
  1. 触发组件的渲染

当app._router 发生变化时触发 vue 的响应式调用render() 将路由相应的组件渲染到中

app._route=route  

hash 模式的实现

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 时触发依赖收集

  • 在组件中使用 this.$router
  • router-link 组件内部

何时触发 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 中

  1. $router.push() //显式调用方法
  2. HashHistory.push() //根据hash模式调用, 设置hash并添加到浏览器历史记录(window.location.hash=XXX)
  3. History.transitionTo() //==>> const route=this.router.match(location, this.current) 找到当前路由对应的组件
  4. History.confirmTransition() // 确认路由,在确认页面跳转后 触发路由守卫,并执行相应回调
  5. History.updateRoute() //更新路由
  6. {app._route=route} // 路由的更改派发更新 触发页面的更新
  7. vm.render() // 在 中进行 render 更新视图
  8. window.location.hash=route.fullpath (浏览器地址栏显示新的路由的path)

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 对象
// 监听当前路由发生变化的时候执行
watch: {
  $route(to, from){
    console.log(to.path)
    // 对路由变化做出响应
  }
}
  • 组件中的 beforeRouteUpdate 路由守卫

在组件被复用的情况下,在同一组件中路由动态传参的变化 如: 动态参数的路径 /foo/:id,在 /foo/1 和 /foo/2 之间跳转的时候,

beforeRouteUpdate(to, from, next){
  // to do somethings
}

2. keep-alive 缓存后获取数据

  • beforeRouteEnter

在每次组件渲染时执行 beforeRouterEnter

beforeRouteEnter(to, from, next){
    next(vm=>{
        console.log(vm)
        // 每次进入路由执行
        vm.getData()  // 获取数据
    })
},
  • actived

在 keep-alive 组件被激活时都会执行 actived 钩子

服务器端渲染期间 avtived 不被调用 


activated(){
	this.getData() // 获取数据
},

总结 当时在写这篇文的时候就是想着尽量能把各个知识点都串联上,建立完善的知识体系 这不写着写着就成了长文, 一旦开始就无法停下,那就硬着头皮继续吧 不过这篇长文真的是有够长的,哈哈哈哈,能坚持看到这里的同学我都感到佩服 如果觉得还有哪里缺失的点可以及时告诉我哦 那么今天就先到这啦