Skip to content

路由懒加载与图片懒加载(Lesson22 3/3) ​

本页对应 Lesson22,共 5 段视频,约 1 小时 41 分。

学习目标 ​

  • 能定位并修复支付页面中“请求进行中取消定时器后仍继续轮询”的 BUG,并通过状态码与定时器状态条件阻止无效请求继续执行。
  • 能使用路由守卫与路由 meta 配置,对加入购物车成功页、提交订单/支付页、支付成功页等页面进行来源与登录权限校验。
  • 能使用 vee-validate 为登录/注册表单配置必填、手机号、密码长度等校验规则,并在提交前根据校验结果阻止非法提交。
  • 能执行 npm run build 完成项目打包,并在 Node 服务中配置 history 路由回退与接口代理,使页面刷新和接口请求正常。
  • 能实现“我的订单”页面:调用订单接口获取数据、渲染订单列表、展示分页,并让页码与 URL 查询参数保持同步。

知识点 ​

路由懒加载要解决的问题 ​

为什么需要:如果路由文件里把所有页面组件都用同步 import 引入,首屏就要把首页、登录、注册、搜索、详情、购物车、交易、支付等页面代码一次性打进来。用户第一次打开页面时,请求的文件更大、等待更久。路由懒加载的目标,是让某个路由组件只在真正访问该路由时再加载。

怎么想:路由表里的 component 最终需要的是一个组件。同步引入是“先拿到组件再配置路由”;懒加载是“先配置一个返回 import() 的函数,等路由命中时再执行”。所以写法上不是改路由表结构,而是改组件变量的定义方式。

怎么做:在 src/router/index.js 中,把页面组件定义成箭头函数返回动态 import():

js
const Home = ()=>import("@/pages/Home");
const Login = ()=>import("@/pages/Login");
const Register = ()=>import("@/pages/Register");
const Search = ()=>import("@/pages/Search");
const Detail = ()=>import("@/pages/Detail");
const AddCartSuccess = ()=>import("@/pages/AddCartSuccess");
const Cart = ()=>import("@/pages/Cart");
const Trade = ()=>import("@/pages/Trade");
const Pay = ()=>import("@/pages/Pay");
const PaySuccess = ()=>import("@/pages/PaySuccess");

之后路由表里仍然写 component: Home 这样的形式即可。import() 返回 Promise,路由解析时会等待它完成。

易错点:不要把懒加载写成同步 import Home from "@/pages/Home";@ 别名要确保在项目里已配置;变量名和路由表里的 component 要对应;同一个组件不要既同步引入又动态引入,否则懒加载可能失效。

优点和缺点的权衡 ​

为什么需要:笔记里明确写了优点:减少网络请求、降低请求文件的大小。但也要记住缺点:如果某一个文件过大,会影响用户体验。懒加载不是“无脑优化”,它把首屏压力拆到访问时,若某个页面本身很大,用户点进去仍会等。

怎么想:按路由拆分是第一步,第二步要看拆出来的文件是否合理。首页、详情、支付等页面体量不同,不能认为加了 ()=>import() 就万事大吉。公共依赖、图片、第三方库也可能让某个 chunk 变大。

怎么做:改造后进入不同路由,打开浏览器网络面板观察请求;打包时执行 npm run build,转写稿里明确说该命令会在项目根目录生成 dist 目录。通过打包产物和网络请求判断是否按路由拆分,再决定是否继续拆组件或优化资源。

易错点:把懒加载当成“所有性能问题都解决了”;只看开发环境不验证打包结果;某个页面文件过大时仍不继续拆分;忽略公共依赖被重复打包或集中打进大文件的问题。

打包上线后的验证与常见坑 ​

为什么需要:懒加载改完后,开发环境能跳转不代表上线没问题。转写稿视频4里打包后生成 dist,并强调“不是直接打开 index.html 就能运行,需要服务支持”。路由采用 history 模式时,刷新子路由会 404,需要服务端把找不到的地址回退到 index.html。

怎么想:先区分两类问题:一类是懒加载是否生效,看网络请求和打包产物;另一类是路由上线配置,看刷新、代理、静态资源。转写稿里点击“注册”后刷新 404,随后用 connect-history-api-fallback 作为中间件解决;代理问题则用 http-proxy-middleware 配置。调试思路是:先看地址和请求,再定位是路由回退还是代理转发。

怎么做:打包后把 dist 交给服务环境,不要直接双击 index.html;history 模式配置回退;接口代理按转写稿思路在服务端配置。验证懒加载时,可像转写稿视频1调试请求那样打开网络面板,观察进入不同路由是否产生新的资源请求。

易错点:把懒加载和路由权限、代理配置混为一谈;打包后直接打开本地文件;刷新子路由 404 却只改前端路由;代理没配就判断接口坏了。懒加载解决的是加载时机和体积拆分,不替代上线服务配置。

本节目标与转写稿说明 ​

本节要完成的是“图片懒加载”:让图片在需要显示时再下载,减少首屏请求压力,提升用户体验。笔记给出的实现路径是:安装 vue-lazyload@1,在入口文件注册插件并配置占位图,最后把需要懒加载的图片从普通 src 改成 v-lazy 指令。

需要说明:提供的转写稿主要记录的是支付定时器问题、路由权限、表单验证、打包和“我的订单”等内容,并没有出现图片懒加载的安装、注册或调试过程。因此下面不把转写稿里无关的支付、路由调试硬套到本节,也不虚构懒加载调试步骤;只按本节笔记和代码展开。转写稿中“路由首位”应纠正为“路由守卫”,“音袋子/因袋子”一类说法应理解为 index 文件,但这些属于其他小节内容,不并入本节。

安装 vue-lazyload@1 ​

为什么需要:图片懒加载不是浏览器默认行为,需要借助插件提供 v-lazy 指令和加载状态管理。笔记指定了 @1 版本,说明本项目要使用与当前 Vue 项目匹配的 1.x 版本,避免直接装到不兼容的大版本。

怎么想:先确认项目能正常安装依赖,再在项目根目录执行安装命令。不要先改代码再装包,否则入口文件引入时会直接报“找不到模块”。

怎么做:按笔记执行:

shell
cnpm install vue-lazyload@1

易错点:命令里的 @1 不要漏;如果包管理器不是 cnpm,要换成项目实际使用的 npm、yarn 或 pnpm,但版本约束仍要保留。安装后检查 package.json 中是否出现对应依赖。

在入口文件注册插件并配置占位图 ​

为什么需要:v-lazy 是插件注册后才能识别的自定义指令。配置 loading 后,图片尚未加载完成时可以显示一张本地占位图,避免空白或布局突兀。

怎么想:入口文件 src/index.js 是全局注册插件的位置。先引入插件,再引入本地 loading.gif,最后通过 Vue.use 注册。占位图路径要使用项目里真实存在的文件。

怎么做:按笔记写入:

js
import VueLazyLoad from "vue-lazyload";
import loading from "@/assets/images/loading.gif"
Vue.use(VueLazyLoad,{
	// 图片未加载前给予呈现的图片
	loading
})

易错点:@/assets/images/loading.gif 的路径、文件名大小写要和实际文件一致;Vue.use 只需在入口执行一次,不要在每个组件里重复注册;如果图片不存在,控制台会出现 404,占位效果也不会出现。

在搜索页给图片加 v-lazy ​

为什么需要:搜索页通常有多张商品图。如果全部用普通 src,页面一打开就会集中请求图片;改成 v-lazy 后,可以把加载时机交给懒加载插件控制。

怎么想:找到需要懒加载的 <img>,确认图片地址来自数据字段。笔记中搜索页 pages/Search/index.vue 使用 item.defaultImg,因此把原来的图片地址绑定改为 v-lazy 指令绑定。

怎么做:按笔记修改:

vue
 <img v-lazy="item.defaultImg" />

易错点:不要写成 :src="item.defaultImg" 后又期待懒加载生效;指令名是 v-lazy,不是 v-lazyload;要确认 item.defaultImg 在渲染时确实有值,否则不是懒加载问题,而是数据字段问题。

验证与常见问题 ​

为什么需要:改完代码不等于生效,需要确认图片请求是否按预期出现,以及占位图是否显示。

怎么想:打开搜索页,观察图片区域;再打开浏览器网络面板,滚动页面时看图片请求是否逐步出现。若未加载前能看到 loading.gif,说明占位配置生效。

怎么做:先重启开发服务,再进入搜索页测试;如果无效果,依次检查依赖是否安装、入口是否注册、组件是否使用 v-lazy、图片字段是否存在。

易错点:只改模板不重启、路径写错、字段名写错、把懒加载指令和普通 src 混用,都会导致“看起来没生效”。本节不涉及转写稿中的支付定时器、路由守卫、表单验证和打包部署,那些问题应放到对应小节处理。

本节要解决什么问题 ​

本节的目标是给特定页面增加“路由权限”:不是所有页面都允许用户随便输入地址进入,而是必须从指定来源页面跳转过来。笔记里列了三个需求:

1- 加入购物车成功界面(指定:搜索(search),详情(detail))----完成
2- 提交订单进入到的支付页面(指定:购物车界面(cart))(作业)
3- 支付成功页面(指定:支付界面(pay))(作业)

为什么需要:像“加入购物车成功”“支付页”“支付成功页”这类页面,本身依赖上一步操作产生的数据或状态。如果用户直接在地址栏输入 /addCartSuccess 也能打开,就是一个 bug。
怎么想:在进入路由之前判断“你从哪来”,来源合法才放行,否则重定向回首页。
怎么做:可以用组件内的 beforeRouteEnter,也可以用路由配置里的 beforeEnter。
易错点:转写稿里出现的“首卫/手位”应纠正为“守卫”,“mete”应纠正为 meta,“form”应纠正为 from,“内幕”应纠正为 name。这些拼写错误会直接导致守卫不生效。

先看错误现象:直接打开也能进 ​

转写稿中的调试过程是:进入购物车、结算、提交订单后能到支付页;但如果在地址栏随便打开某些页面,也能打开,这本身就不允许。对于加入购物车成功页,理想情况是:只有从搜索页 search 或详情页 detail 加入购物车后,才允许进入;直接打开或刷新时,应该被拦回首页。

调试时重点看三件事:

  1. 直接打开 /addCartSuccess 是否被拦截;
  2. 从搜索页加入购物车是否能进入;
  3. 从详情页加入购物车是否能进入;
  4. 如果给来源路由加了 meta 标记,但拼写错了,会出现“从搜索页也进不去”的现象。转写稿里就出现过“少了个 meta”导致进不去,补上后才正常。

判断来源:from.name 与 meta 标记 ​

为什么需要:最直接的写法是判断 from.name 是否在 ["detail","search"] 里。笔记方案一里也保留了这种注释写法:

js
// if(["detail","search"].includes(from.name)){
//     next();
// }else next("/")

怎么想:这种写法能完成当前需求,但后面如果再加一个来源页面,就要回来改数组,不够灵活。转写稿里建议改用 meta:在允许进入的来源路由上增加一个标记,例如 meta: { goCartSuccess: true },守卫里只判断 from.meta.goCartSuccess。这样以后新增来源,只要在对应路由加标记,不用改守卫逻辑。

怎么做:在 search 和 detail 的路由配置里加 meta 标记,然后在守卫里判断。
易错点:meta 要加在“来源路由”上,不是加在目标路由上;from.meta 可能为空,实际项目里可写成 from.meta && from.meta.goCartSuccess,但笔记代码按原样保留。

方案一:组件内 beforeRouteEnter ​

位置:src/pages/AddCartSuccess/index.vue。

js
beforeRouteEnter(to,from,next){
    // 只有来自于搜索页以及详情页才可以允许进入到该路由
    // if(["detail","search"].includes(from.name)){
    //     next();
    // }else next("/")

    if(from.meta.goCartSuccess) next();
    else next("/")
}

为什么需要:守卫写在页面组件内部,和这个页面关系最紧,适合只控制单个页面。
怎么想:to 是目标路由,from 是来源路由,next() 放行,next("/") 重定向首页。
怎么做:在来源路由加 meta.goCartSuccess,目标页写上述守卫。
易错点:beforeRouteEnter 里不能直接访问 this,不过这里不需要;next 只能调用一次;from.meta 拼错、goCartSuccess 大小写不一致都会失败。

方案二:路由独享守卫 beforeEnter ​

位置:src/router/index.js 中对应路由配置。

js
{
    // 成功加入购物车界面
    path:"/addCartSuccess",
        component:AddCartSuccess,
            beforeEnter(to,from,next){
            if(from.meta.goCartSuccess) next();
            else next("/")
        }
}

为什么需要:把权限判断集中到路由配置里,路由结构更清晰,适合团队统一管理。
怎么想:beforeEnter 是“路由独享守卫”,只对当前这条路由生效。
怎么做:把判断逻辑从组件里搬到路由对象中;来源路由仍然要配置 meta.goCartSuccess。
易错点:beforeEnter 要写在目标路由对象内部,不是全局 beforeEach;不要和方案一重复写两份逻辑,否则容易重复 next。

调试与作业 ​

调试顺序建议:先直接打开 /addCartSuccess,应回首页;再从搜索页加入购物车,应能进入;再从详情页加入购物车,应能进入;最后检查刷新或新窗口打开时是否被拦截。转写稿里“从首页直接打开不行、从搜索/详情可以、刷新不行”都是正常现象。

两个作业按同样思路做:

  • 支付页:只允许从 cart 进入,可在 cart 路由加 meta 标记,在支付路由的 beforeEnter 或支付页 beforeRouteEnter 判断 from.meta。
  • 支付成功页:只允许从 pay 进入,同样在 pay 路由加标记,在支付成功页判断。

易错点:作业里不要只写 from.name === "cart" 就结束,建议按本节思路用 meta 标记,后续扩展更灵活;重定向地址要写项目里真实存在的首页路由。

安装与引入:先让项目具备校验能力 ​

为什么需要:登录页的手机号、密码不能只靠后端报错,前端要在提交前给出即时提示。手写校验会分散在组件里,规则难复用。使用 vee-validate 可以把“规则定义”和“模板提示”分开。

怎么想:本项目是 Vue2,所以选择 vee-validate@2。转写稿里特别强调:Vue2 对应 v2,Vue3 对应 v4;v3 是过渡版本,不要混用。安装时如果漏写版本,可能装成 4.x,需要重新指定版本安装。

怎么做:先安装,再在 src/utils/validate.js 中统一注册插件和规则,最后在 src/index.js 引入一次。

shell
cnpm i vee-validate@2
js
import "./utils/validate";

易错点:Vue.use(VeeValidate) 只应执行一次;规则文件要被入口引入,否则模板里的 v-validate 不生效。安装后组件实例会多出 $validator、errors 等能力,可用 mounted(){ console.log(this) } 观察,但不要把调试代码留在最终页面。

重写 required:让错误提示变成中文 ​

为什么需要:插件自带提示是英文,且默认“必填”未必符合项目要求。这里要求空字符串、纯空格都算不合法。

怎么想:Validator.extend("required", {...}) 可以覆盖同名规则。validate 返回布尔值:true 表示符合规则,false 表示不符合。getMessage 在不符合时返回提示文本,参数可拿到 input 的 name。

怎么做:在 validate.js 中重写 required,同时扩展手机号、密码规则。

js
import Vue from "vue";
import VeeValidate,{Validator} from "vee-validate"
import {phoneReg, pwdReg} from "@/utils/reg";
Vue.use(VeeValidate);

// 重写规则:
Validator.extend("required",{
	// 该函数的返回值是一个布尔,为true说明符合规则,false不符合规则
	validate:value=>value.trim().length>0,
	// 当规则为false时,返回的值即是错误显示信息
	// name是input->name属性值
	getMessage(file) {
		return file+"不允许为空!"
	}
});

// 验证手机号:
Validator.extend("phone_rule",{
	// 该函数的返回值是一个布尔,为true说明符合规则,false不符合规则
	validate:value=>phoneReg.test(value),
	// 当规则为false时,返回的值即是错误显示信息
	// name是input->name属性值
	getMessage:()=>"请输入正确的手机号!"
});

// 验证密码:
Validator.extend("pwd_rule",{
	// 该函数的返回值是一个布尔,为true说明符合规则,false不符合规则
	validate:value=>pwdReg.test(value),
	// 当规则为false时,返回的值即是错误显示信息
	// name是input->name属性值
	getMessage:()=>"请输入6至21位密码!"
});

易错点:value 可能是空值,直接 .trim() 前要确认输入场景;规则名要和模板里完全一致,phone_rule 不能写成 phoneRule。转写稿调试时曾出现“规则写了但没提示”,原因是模板没有使用该规则,或 input 没有 name,错误无法关联到字段。

模板绑定与错误显示:把规则挂到输入框 ​

为什么需要:规则定义好后,必须让具体 input 声明“用哪些规则”,并指定错误显示位置。

怎么想:v-validate="{required:true,phone_rule:true}" 表示该字段同时应用两条规则;name="phone" 是错误信息的关联键;errors.first("phone") 取该字段第一条错误。

怎么做:在登录组件中绑定手机号、密码,并在提交时先统一校验。

vue
<template>
    <!-- 登录 -->
    <div class="login-wrap">
        <div class="login">
            <div class="loginform">
                <div class="content">
                    <form @submit.prevent="login">
                        <div class="input-text clearFix">
                            <i></i>
                            <input
                                    v-validate="{required:true,phone_rule:true}"
                                    type="text"
                                    name="phone"
                                    placeholder="手机号">
                            <span class="error-msg">{{errors.first("phone")}}</span>
                        </div>

                        <div class="input-text clearFix">
                            <i class="pwd"></i>
                            <input v-validate="{required:true,pwd_rule:true}" type="text" name="password" placeholder="请输入密码">
                            <span class="error-msg">{{errors.first("password")}}</span>
                        </div>

                        <button class="btn">登&nbsp;&nbsp;录</button>
                    </form>
                    <div class="call clearFix">
                        <router-link to="/register" class="register">立即注册</router-link>
                    </div>
                </div>
            </div>
        </div>
    </div>
</template>

<script>
export default {
    name: "Login",
    methods:{
        // 1
        async login(e){
            // 所有的规则全部满足为true,有一个不满足为false
            const result = await this.$validator.validateAll();
           if(result){
               const phone = e.target.phone.value.trim();
               const password = e.target.password.value.trim();
               await this.$store.dispatch("user/postLoginAsync",{
                   phone,
                   password
               })
           }

        }
    },
    mounted(){
        console.log(this)
    }
}
</script>

易错点:validateAll() 返回 Promise,必须 await 后再判断 result;不要在未校验通过时直接发登录请求。转写稿中调试时看到 result 为 false 才阻止提交,为 true 才继续。若用 e.target.phone 取值,input 必须有 name="phone";错误提示里的字段名也要与 name 一致。本节定位是“了解”,重点是理解规则返回值、getMessage 和提交前统一校验的流程。

打包命令与 dist 目录 ​

为什么需要:项目开发完成后不能直接把源码目录交给运维或上线,生产环境需要的是打包后的静态资源。笔记里给出的命令是:

shell
cnpm run build

转写稿里也提到等价的 npm run build。执行完该命令后,会在项目根目录中生成一个名字为 dist 的目录。

怎么想:打包的本质是把 Vue 项目里的组件、样式、图片等编译、压缩成浏览器可直接请求的静态文件。看到终端输出结束后,再去项目根目录确认是否出现 dist。

怎么做:执行命令,等待打包完成,然后进入 dist 查看生成的 index.html、CSS、JS、图片等资源。

易错点:不要以为打包完就能直接双击 dist/index.html 打开。转写稿里明确调试过:直接打开时页面资源路径不对,因为打包后的资源通常按根目录 / 去找,必须由服务托管。另一个易错点是命令要写在项目根目录,且依赖已安装。

把 dist 复制到 site 目录 ​

为什么需要:笔记要求“复制 dist 目录内的文件至目录 site”。这样 site 就是实际部署目录,里面既有前端静态文件,也有后面要写的 server.js。

怎么想:不要把整个项目源码和 node_modules 直接丢给运维;上线目录应只保留打包产物和服务启动文件。转写稿里也强调“打完包不是啪就能运行,需要服务的支持”。

怎么做:把 dist 里面的文件复制到 site,然后在 site 下准备 server.js。

易错点:复制的是 dist 目录“内的文件”,不是把 dist 文件夹再套一层,否则静态资源路径容易多一层。server.js 要放在 site 目录下,后面 express.static(__dirname) 才能正确指向当前目录。

用 Express 托管静态资源 ​

为什么需要:打包后的页面必须通过 HTTP 服务访问,不能直接本地文件打开。笔记中的 site/server.js 先引入 Express:

js
const express = require("express");
// 解决路由404问题第一步下载:cnpm install connect-history-api-fallback
// 解决路由404问题第二步引入:
const history = require("connect-history-api-fallback");
// 解决代理问题第一步下载:cnpm i http-proxy-middleware
// 解决代理问题第二步引入:
const {createProxyMiddleware} = require("http-proxy-middleware")

const app = express();
// 解决路由404问题第三步:将history当作中间件使用,当找不到指定的路由时,会将index.html作为渲染的界面
app.use(history());
// 将当前目录设置为静态资源
app.use(express.static(__dirname));

// 解决代理问题第三步:配置代理
app.use("/api",createProxyMiddleware({
	target:"http://sph-h5-api.atguigu.cn",
	changeOrigin:true
}))
app.use("/my",createProxyMiddleware({
	target:"http://zhangpeiyue.com:8082",
	changeOrigin:true,
	pathRewrite:{
		"^/my":""
	}
}))
app.listen(80,()=>{
	console.log("success");
})

怎么想:先让服务能返回 index.html 和静态资源,再处理前端路由刷新 404,最后处理接口代理。转写稿中的调试顺序也是这样:先启动服务访问页面,发现刷新注册等页面 404,再加 history;发现“全部商品”等接口拿不到数据,再配代理。

怎么做:在 site 下安装依赖:

shell
cnpm install express
cnpm install connect-history-api-fallback
cnpm i http-proxy-middleware

然后运行 server.js,访问 http://localhost 验证。

易错点:app.use(history()) 要放在 express.static(__dirname) 之前。它的作用是当找不到指定路由时,把 index.html 作为渲染界面返回;如果顺序反了,刷新子路由仍可能 404。express.static(__dirname) 表示把当前目录作为静态资源目录,__dirname 不要写错。

路由 404 与 history 模式 ​

为什么需要:转写稿里点击“注册”后刷新页面就没了,原因是前端使用 history 路由,刷新时浏览器会向服务器请求 /register 这类地址,而服务器目录里并没有这个真实文件。笔记注释写得很清楚:connect-history-api-fallback 用于“当找不到指定的路由时,会将 index.html 作为渲染的界面”。

怎么想:服务端不认识前端路由,需要中间件兜底到 index.html,再由前端路由解析地址。

怎么做:按笔记三步:下载、引入、app.use(history())。启动后刷新子页面验证是否还在。

易错点:不要把它当成接口代理;它只解决前端路由刷新 404。若页面能打开但接口没数据,应检查代理,而不是继续改 history。

代理配置与启动验证 ​

为什么需要:打包后前端请求 /api 等接口时,如果没有代理,请求会打到当前静态服务器上,拿不到真实后端数据。笔记用 http-proxy-middleware 配置了两个代理:/api 指向 http://sph-h5-api.atguigu.cn,/my 指向 http://zhangpeiyue.com:8082,并用 pathRewrite 把 /my 前缀去掉。

怎么想:代理三步与路由问题类似:下载模块、引入 createProxyMiddleware、用 app.use 配置。changeOrigin:true 用于改变请求源;pathRewrite 用于重写路径。

怎么做:安装并引入后,把笔记中的代理代码放入 server.js,重启服务,打开 Network 面板观察接口是否返回数据。

易错点:改完 server.js 必须重启服务。转写稿还提醒:如果前端页面路径以 /my 开头,可能被 /my 代理拦截,所以订单等页面命名要避开代理前缀,或调整代理配置。最后启动成功时控制台会输出 success,再访问页面、刷新子路由、检查接口,三项都通过才算部署验证完成。

课堂画面 ​

课堂画面 1

课堂画面 2

课堂画面 3

课堂画面 4

课堂画面 5

代码示例 ​

1. 路由懒加载 + 路由权限(src/router/index.js) ​

js
import Vue from "vue";
import VueRouter from "vue-router";

Vue.use(VueRouter);

/* ---------------- 路由懒加载:用到哪个路由才去请求哪个 chunk ---------------- */
const Home = () => import("@/pages/Home");
const Login = () => import("@/pages/Login");
const Register = () => import("@/pages/Register");
const Search = () => import("@/pages/Search");
const Detail = () => import("@/pages/Detail");
const AddCartSuccess = () => import("@/pages/AddCartSuccess");
const Cart = () => import("@/pages/Cart");
const Trade = () => import("@/pages/Trade");
const Pay = () => import("@/pages/Pay");
const PaySuccess = () => import("@/pages/PaySuccess");

const routes = [
  { path: "/", name: "home", component: Home },
  { path: "/login", name: "login", component: Login },
  { path: "/register", name: "register", component: Register },

  // 允许作为“加入购物车成功页”来源的两个页面
  { path: "/search", name: "search", component: Search, meta: { goCartSuccess: true } },
  { path: "/detail/:skuId", name: "detail", component: Detail, meta: { goCartSuccess: true } },

  // 允许作为“结算(提交订单)页”来源的页面
  { path: "/cart", name: "cart", component: Cart, meta: { goTrade: true } },
  // 允许作为“支付成功页”来源的页面
  { path: "/pay", name: "pay", component: Pay, meta: { goPaySuccess: true } },

  // 路由独享守卫:只有来源路由的 meta 打了标记才放行
  {
    path: "/addCartSuccess",
    name: "addCartSuccess",
    component: AddCartSuccess,
    beforeEnter(to, from, next) {
      if (from.meta && from.meta.goCartSuccess) next();
      else next("/");
    }
  },
  {
    path: "/trade",
    name: "trade",
    component: Trade,
    beforeEnter(to, from, next) {
      if (from.meta && from.meta.goTrade) next();
      else next("/");
    }
  },
  {
    path: "/paySuccess",
    name: "paySuccess",
    component: PaySuccess,
    beforeEnter(to, from, next) {
      if (from.meta && from.meta.goPaySuccess) next();
      else next("/");
    }
  }
];

export default new VueRouter({ routes });

关键行与易错点

  • () => import("@/pages/Home"):返回 Promise,webpack 自动拆包。首屏只加载 Home 的 chunk,减少首次请求体积;缺点是某个 chunk 过大时,进入该页会有一段白屏,需要配合 loading。
  • 标记打在来源路由的 meta 上(search / detail),守卫写在目标路由上(addCartSuccess),判断的是 from 而不是 to——写反就永远进不去。
  • next("/") 与 next() 只能二选一且必须调用一次;既调 next() 又调 next("/") 会报错。
  • 直接输入地址或刷新时,from 是空路由,from.meta 为 {},条件不成立 → 跳首页,这正是预期行为。
  • 另一种等价方案是在组件里写 beforeRouteEnter(to, from, next),注意此时 this 拿不到组件实例。
  • 转写稿里还演示过 if (["detail","search"].includes(from.name)) 的写法,但加页面就得改数组、不够灵活,所以最终改成 meta 标记方案。

2. 图片懒加载 + 表单验证(src/index.js、src/utils/validate.js、src/pages/Login/index.vue) ​

js
// src/index.js
import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";

import VueLazyLoad from "vue-lazyload";
import loading from "@/assets/images/loading.gif";
import "./utils/validate";

Vue.use(VueLazyLoad, {
  // 图片未加载前给予呈现的图片
  loading
});

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount("#app");
js
// src/utils/validate.js
import Vue from "vue";
import VeeValidate, { Validator } from "vee-validate";
import { phoneReg, pwdReg } from "@/utils/reg";

Vue.use(VeeValidate);

// 重写 required 规则
Validator.extend("required", {
  // 返回 true 说明符合规则,false 不符合
  validate: value => value.trim().length > 0,
  // 规则为 false 时,返回值即错误提示信息
  getMessage(file) {
    return file + "不允许为空!";
  }
});

// 验证手机号
Validator.extend("phone_rule", {
  validate: value => phoneReg.test(value),
  getMessage: () => "请输入正确的手机号!"
});

// 验证密码
Validator.extend("pwd_rule", {
  validate: value => pwdReg.test(value),
  getMessage: () => "请输入6至21位密码!"
});
vue
<!-- src/pages/Login/index.vue(样式略,不影响逻辑) -->
<template>
  <div class="login-wrap">
    <div class="login">
      <div class="loginform">
        <div class="content">
          <form @submit.prevent="login">
            <div class="input-text clearFix">
              <i></i>
              <input
                v-validate="{required:true,phone_rule:true}"
                type="text"
                name="phone"
                placeholder="手机号">
              <span class="error-msg">{{errors.first("phone")}}</span>
            </div>

            <div class="input-text clearFix">
              <i class="pwd"></i>
              <input
                v-validate="{required:true,pwd_rule:true}"
                type="text"
                name="password"
                placeholder="请输入密码">
              <span class="error-msg">{{errors.first("password")}}</span>
            </div>

            <button class="btn">登&nbsp;&nbsp;录</button>
          </form>
          <div class="call clearFix">
            <router-link to="/register" class="register">立即注册</router-link>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  name: "Login",
  methods: {
    async login(e) {
      // 所有规则全部满足为 true,有一个不满足为 false
      const result = await this.$validator.validateAll();
      if (result) {
        const phone = e.target.phone.value.trim();
        const password = e.target.password.value.trim();
        await this.$store.dispatch("user/postLoginAsync", { phone, password });
      }
    }
  }
};
</script>

关键行与易错点

  • 图片懒加载:安装 vue-lazyload@1 后 Vue.use(VueLazyLoad, { loading }),loading 是占位图;模板里把 src 换成 v-lazy,如 Search 页的 <img v-lazy="item.defaultImg" />。不要再同时写 :src,两者会互相覆盖。
  • Validator.extend(名字, { validate, getMessage }):validate 返回 true 才算通过,getMessage 返回的是不通过时显示的文案。
  • v-validate 的值是对象 {required:true, phone_rule:true},键名必须和 extend 的第一个参数完全一致,否则规则写了也不生效(转写稿里就踩过"定了规则没用上"的坑)。
  • errors.first("phone") 的参数必须等于 input 的 name;把 name 改成中文,模板里的 errors.first("phone") 也得同步改。
  • 重写 required 用的是 value.trim().length > 0,比内置的 required 更严格(内置不拦纯空格)。
  • 提交必须用 await this.$validator.validateAll() 拿到布尔值再发请求,不能只靠按钮样式或 errors.any() 判断。
  • 易错:e.target.phone.value 依赖 input 的 name="phone",转写稿中特别强调——如果改用 ref 或 v-model 收集数据,这里就要跟着改,否则取不到值。

3. 打包部署(site/server.js) ​

js
// 复制 dist 目录内的文件至 site 目录,在 site 下执行 node server.js
const express = require("express");
// 解决路由404问题第一步:cnpm install connect-history-api-fallback
const history = require("connect-history-api-fallback");
// 解决代理问题第一步:cnpm i http-proxy-middleware
const { createProxyMiddleware } = require("http-proxy-middleware");

const app = express();

// 解决路由404问题第二步:当找不到指定路由时,将 index.html 作为渲染的界面
app.use(history());

// 将当前目录设置为静态资源
app.use(express.static(__dirname));

// 解决代理问题第三步:配置代理
app.use("/api", createProxyMiddleware({
  target: "http://sph-h5-api.atguigu.cn",
  changeOrigin: true
}));

app.use("/my", createProxyMiddleware({
  target: "http://zhangpeiyue.com:8082",
  changeOrigin: true,
  pathRewrite: {
    "^/my": ""
  }
}));

app.listen(80, () => {
  console.log("success");
});

关键行与易错点

  • 打包命令 cnpm run build,产物在项目根目录的 dist;把 dist 里的文件复制到 site,再在 site 下起服务。
  • app.use(history()) 必须写在 express.static 之前,否则刷新子路由仍然是 404。
  • history 中间件的作用:找不到对应资源时回退渲染 index.html,交给前端路由处理;这也是 history 模式上线必须配置的原因。
  • 代理用 createProxyMiddleware(解构引入,新版本 API),/api 直接转发,/my 通过 pathRewrite 去掉前缀。
  • 易错:直接双击 index.html 打开会白屏——打包后资源是绝对路径 /js/xxx.js,必须有服务托管才能访问。
  • 易错:接口前缀 /my 会和前端路由(比如“我的订单” /myOrder)冲突,转写稿里就是把 /my 代理去掉或给路由改名来解决的。
  • 端口 80 需要管理员权限;app.use("/api", ...) 与 express.static 的顺序保持上面这样,避免静态资源拦截接口请求。

常见错误 ​

  • 支付页点击取消/关闭后仍继续发请求,慢网下更明显 → 只 clearTimeout 了当前定时器,未让在途请求回调失效,回调返回后又 setTimeout → 增加 isCancelled/isTimeout 标志,取消时置位并 clearTimeout、timer=null;请求成功/失败回调先判断标志再继续,异常状态码也阻断后续轮询。
  • 加入购物车成功页直接输入 URL 仍能进入 → 来源路由未配置 meta.goCartSuccess,或守卫只判断 from.name 但路由没配 name,动态 path 又不好硬判 → 来源路由统一加 meta 标记,目标路由用 beforeEnter 读 from.meta,未授权 next("/");不要在组件里散落硬编码判断。
  • v-lazy 图片不懒加载或不显示占位图 → 入口未 Vue.use(VueLazyLoad,{loading})、版本不匹配,或指令写成 v-lazy:src、loading 路径错误 → Vue2 使用 vue-lazyload@1,入口注册并引入占位图,模板使用 v-lazy="item.defaultImg"。
  • 表单验证规则不生效、错误提示不出、校验失败仍提交 → Validator.extend 规则名与 v-validate 不一致,input name 与 errors.first() 不一致,或未 await validateAll() 判断结果 → 规则名、name、错误 key 三处对齐;提交方法用 async,先 const ok=await this.$validator.validateAll(); if(!ok) return; 再发请求。
  • 打包后刷新子路由 404 → history 路由模式,Express 未挂 connect-history-api-fallback → app.use(history()) 后再 express.static,让找不到的路由回退到 index.html。
  • 打包后接口 404 或跨域 → 开发环境的 devServer.proxy 不会随打包生效,生产服务未配代理 → Express 中用 http-proxy-middleware 配 /api 代理,设置 changeOrigin,按需 pathRewrite。
  • 订单分页刷新回到第一页、页码与请求数据不一致 → 页码只存组件 data,未同步 URL query,也未监听路由变化 → 初始化从 $route.query.page 取默认 1,切页 push query,watch $route.query.page 重新拉数据。
  • 订单表格跨行错位 → 详情条数动态变化,但首列 rowspan 写死 → 用 item.orderDetailList.length 动态绑定 rowspan,只在首行渲染跨行单元格。

练习 ​

  1. 修复支付页轮询:实现点击取消/关闭后不再发请求;用慢网复现“在途请求返回后又起定时器”的场景,并说明你的标志位清理点。
  2. 完成 /trade 与 /paySuccess 的路由权限:/trade 只允许从 cart 进入,/paySuccess 只允许从 pay 进入;直接输入 URL 必须被重定向,分别用路由独享守卫实现。
  3. 给订单列表加分页并与 URL query 同步:刷新保留当前页,切页请求对应页,从详情返回列表能恢复页码。
  4. 部署 Express 服务:配置 history fallback 与 /api 代理,验证刷新 /myOrder 不 404、接口请求不跨域。
  5. 完成登录/注册表单验证:手机号与密码规则、错误提示、validateAll 不通过不提交;注册页照登录页迁移并验证规则名与 name 对齐。

原视频对照 ​

#视频时长
1解决BUG09:27
2增加路由权限13:12
3表单验证27:50
4打包18:07
5我的订单33:03

小结 ​

  • 懒加载是体积与体验的权衡:路由按页拆分、图片用占位,但要警惕单个 chunk 过大。
  • 权限校验要收口到路由层,用 meta 做来源标记,避免组件内散落硬编码和动态 path 判断。
  • 异步清理类 bug 的核心是“在途请求 + 定时器 + 标志位”,取消必须让后续回调失效,而不只是清当前定时器。
  • 表单验证是提交前防线:规则名、name、错误 key 必须一致,最终仍要服务端校验。
  • 打包后能打开不等于可部署,优先排查 history 路由回退与生产代理。
  • 列表分页、筛选等状态优先同步 URL,组件状态只负责渲染,刷新和返回才可恢复。
  • 动态表格的 rowspan/colspan 必须由数据长度计算,不能写死。
  • 修复前先用慢网、直接输 URL、刷新、取消操作复现边界,再改代码验证。