Vue-Cli3 MPA

创建应用

$ node -v
v12.14.0

$ npm -v 
6.13.4

$ npm i -g @vue/cli

$ vue -V
@vue/cli 4.1.2

$ vue create stats

创建Vue项目选择基础预设,其中包括babel和eslint插件。

文件结构

文件 描述
public 静态资源文件夹
src 项目源代码包括目录
src/config 应用配置文件夹,推荐使用JSON格式。
src/utils 工具类库文件夹
src/assets 静态资源文件夹
src/components 组件文件夹,根据单页划分模块,一个单页一个文件夹,保存单页所使用的组件。
src/entry 多页入口文件夹,主要是JS文件。
src/pages 多页文件夹,主要是vue文件。
src/router 路由文件夹,每个单页一个路由。

项目文件结构说明,为合理规划文件组织结构。会将MPA应用中每个page的.html.js.vue三个文件分别划分到publicsrc/entrysrc/pages文件夹下。

应用配置

创建vue核心配置

$ cd stats
$ vim vue.config.js

安装组件

$ npm i -S path
const path = require("path");
const utils = require("./src/utils/utils");

//是否开发调试模式
const debug = process.env.NODE_ENV === "development" ? true : false;

module.exports = {
    publicPath:debug?"/":"",
    outputDir:"dist",
    assetsDir:"assets",
    filenameHashing:true,
    lintOnSave:!debug,
    runtimeCompiler:!debug,
    pages:utils.getPages(),
    configureWebpack:config=>{
        const extensions = [".js", ".json", ".vue", ".css"];
        const alias = {
            "@":path.join(__dirname, "src"),
            "src":path.join(__dirname, "../src"),
            "assets":path.join(__dirname, "../src/assets"),
            "components":path.join(__dirname, "../src/components")
        };
        config.resolve = {extensions, alias};
    }
};

pages选项

vue核心配置项中的pages选项为多页应用MPA的配置位置,提取出来放到工具类库utils/utils.js文件中。

pages配置是Object类型,默认值为undefined,在multi-page模式下构建应用。每个page对应一个JavaScript入口文件。

pages的值是一个对象,对象的key为page单页入口名称,value是一个指定entry、template、filename、title、chunks的对象。其中entry为必填且为字符串。

page选项

page选项 必填 描述
entry page入口JS文件路径
template page模板文件路径
filename 输出到dist目录中的文件名
title 页面title标签内容
chunks page中包含的块,默认会提取通用块。

示例代码

module.exports = {
  pages: {
    index: {
      // page 的入口
      entry: 'src/index/main.js',
      // 模板来源
      template: 'public/index.html',
      // 在 dist/index.html 的输出
      filename: 'index.html',
      // 当使用 title 选项时,
      // template 中的 title 标签需要是 <title><%= htmlWebpackPlugin.options.title %></title>
      title: 'Index Page',
      // 在这个页面中包含的块,默认情况下会包含
      // 提取出来的通用 chunk 和 vendor chunk。
      chunks: ['chunk-vendors', 'chunk-common', 'index']
    },
    // 当使用只有入口的字符串格式时,
    // 模板会被推导为 `public/subpage.html`
    // 并且如果找不到的话,就回退到 `public/index.html`。
    // 输出文件名会被推导为 `subpage.html`。
    subpage: 'src/subpage/main.js'
  }
}

根据page选项,并配合项目组织结构,将每个pageentry入口文件都保存到src/entry文件夹下,入口文件均为JS文件,template模板文件均使用public/index.htmltitle标签内容每个页面均不一样,后续会进行处理,默认使用入口名称。这些内容均会在提取到工具类库src/utils/utils.js文件的getPages()方法中。

工具类库

创建工具类库

$ vim src/utils/utils.js

安装组件

$ npm i -S fs glob
const fs = require("fs");
const path = require("path");
const glob = require("glob");

const pagePath = path.resolve(__dirname, "..", "pages");
const entryPath = path.resolve(__dirname, "..", "entry");
const configPath = path.resolve(__dirname, "..", "config");

/*获取配置*/
exports.config = (filename,field="")=>{
    const file = path.join(configPath, filename);
    let value = require(file);
    if(field!==""){
        value = value[field];
    }
    return value;
};

/*获取多页面配置选项*/
exports.getPages = ()=>{
    let pages = {};
    //获取所有vue文件
    let files = glob.sync(`${pagePath}/*/*.vue`);
    if(files.length < 1){
        console.error("util getPages no file");
    }
    files.forEach(filepath=>{
        const extname = path.extname(filepath);
        const basename = path.basename(filepath, extname);
        //统一入口文件保存路径
        const entry = path.join(entryPath, `${basename}.js`);//绝对路径
        //自动生成入口文件
        const exists = fs.existsSync(entry);
        console.log(exists, entry);
        if(!exists){
            let code = `import Vue from 'vue';\n`;
            code += `import App from '${filepath}';\n`;
            code += `Vue.config.productionTip = false;\n`;
            code += `new Vue({render:h=>h(App)}).$mount('#${basename}');`;
            fs.writeFileSync(entry, code);
        }
        //页面配置选项
        const template = "index.html";
        const filename = `${basename}.html`;
        const chunks = ['chunk-vendors', 'chunk-common', basename];
        const chunksSortMode = "manual";
        const minify = false;
        const inject = true;
        //自定义页面数据
        const pageData = this.config("page", basename) || {};
        if(pageData.title === undefined){
            Object.assign(pageData, {title:basename});
        }
        if(pageData.idname === undefined){
            Object.assign(pageData, {idname:basename});
        }
        pages[basename] = {entry, template, filename, pageData, chunks, chunksSortMode, minify, inject};
    });
    return pages;
};

getPages()方法对vue.config.js中的pages参数进行提取并根据提前规划好的结构进行组织文件,其中会判断入口文件是否已经存在,若不存在则会生成。

模板文件

$ vim public/index.html
<% const page = htmlWebpackPlugin.options.pageData; %>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <title><%= page.title %></title>
</head>
<body>
<noscript>
    <strong>很抱歉,如果没有启用javascript,vue-cli3无法正常工作。请启用它以继续。</strong>
</noscript>
<div id="app">
    <div id="<%= page.idname %>"></div>
</div>
</body>
</html>

页面配置

$ vim src/config/page.json
{
  "index":{"title":"index page"}
}

单页应用

多页应用基础结构搭建完毕后,接下来针对index单页进行开发。每个单页分为入口、路由、布局、模板、组件、样式等一系列部分组成。

$ vim src/pages/index/index.vue
<template>
    <div class="page">
        <router-view></router-view>
    </div>
</template>

<script>
    export default {
        name: "index.vue"
    }
</script>

<style scoped>

</style>

作为index单页,默认会加载页面布局组件,创建.vue文件会自动生成基础的入口文件。入口文件中会加载路由和UI组件。

入口文件

安装组件

$ npm i -S vant less less-loader

项目使用vant作为UI组件,vant是有赞团队基于有赞统一的规范实现的一个轻量、可靠的移动端Vue组件库,用于移动端开发。由于vant使用less,因此需配置less和less-loader。

$ vim src/entry/index.js
import Vue from 'vue';
import Vant from  "vant";

import router from "../router/index.js";
import app from '../pages/index/index.vue';
import layout from "../components/index/layout.vue";
// import "vant/lib/index.less";

Vue.config.productionTip = false;
Vue.use(Vant);


new Vue({
    render:h=>h(app),
    router:router,
    components:{layout},
    template:"<layout/>"
}).$mount('#index');

路由文件

安装组件

$ npm i -S vue-router
$ vim src/router/index.js
import Vue from "vue";
import Router from "vue-router";

import layout from "../components/index/layout.vue";

Vue.use(Router);

const routes = [
    {
        path:"/index/layout",
        name:"layout",
        meta:{title:"index layout", requireAuth:false},
        component:layout
    }
];
export default new Router({
    mode:"history",
    base:process.env.BASE_URL,
    routes
});

vant

Vant是由有赞前端团队开发的一款轻量、可靠的移动端Vue组件库。

安装组件

$ npm i -S vant
$ yarn add vant

babel-plugin-import

引入组件

babel-plugin-import是一款babel插件,会在编译过程中将import的写法自动转换为按需引入的方式。

使用vant组件可以一次性全局导入组件,但这种做法会带来增加代码包的体积,因此推荐使用babel-plugin-import进行按需加载,以节省资源。

$ npm i -D babel-plugin-import
$ npm i --save-dev babel-plugin-import

备注:若项目仅在开发环境下需要npm包而在上线后不需要,则是可使用--save-dev-D。若上线后需要使用依赖包则需使用--save-S

查看babel-plugin-import版本

$ npm view babel-plugin-import version
1.13.0
$ npm ls babel-plugin-import
sxyh_web_stats@0.1.0 D:\vue\workspace\sxyh_web_stats
`-- babel-plugin-import@1.13.0

查看babel版本

$ npm info babel version
6.23.0

配置插件

$ vim babel.config.js
module.exports = {
    presets: ['@vue/cli-plugin-babel/preset'],
    plugins: [
        ['import', {
            libraryName: 'vant',
            libraryDirectory: 'es',
            style: true
        }, 'vant']
    ]
};

配置按需引入后将不再允许直接导入所有组件,虽然Vant支持一次性导入所有组件,但直接导入所有组件会增加代码包体积,因此并不推荐这种做法。

按需引入后,在vue文件中使用组件时,首先需要导入所需使用的组件,然后在Vue中进行注册组件。注册组件后,才能使用vant提供的组件标签。

$ vim home.vue
<template>
    <div class="layout">
        <van-nav-bar title="排行榜" left-text="返回" right-text="按钮" left-arrow @click-left="onClickLeft" @click-right="onClickRight"/>
        <van-image round width="5rem" height="5rem" src="https://img.yzcdn.cn/vant/cat.jpeg"/>
        <van-panel title="我的昵称" desc="ID:123456" status="第10名"></van-panel>
        <van-list v-model="loading" :finished="finished" finished-text="没有更多了" @load="onLoad">
            <van-cell v-for="item in list" :key="item" :title="item" />
        </van-list>
        <van-tabbar v-model="active">
            <van-tabbar-item icon="home-o">排行榜</van-tabbar-item>
            <van-tabbar-item icon="search">积分</van-tabbar-item>
            <van-tabbar-item icon="friends-o">茶馆</van-tabbar-item>
            <van-tabbar-item icon="setting-o">分组</van-tabbar-item>
        </van-tabbar>
    </div>
</template>

<script>
    import {NavBar, Image, Panel, List, Cell, Tabbar, TabbarItem, Toast} from "vant";
    export default {
        name: "layout",
        data(){
            return {
                active:0,
                list:[],
                loading:false,
                finished:false
            };
        },
        //注册组件
        components:{
            [NavBar.name]:NavBar,
            [Image.name]:Image,
            [Panel.name]:Panel,
            [List.name]:List,
            [Cell.name]:Cell,
            [Tabbar.name]:Tabbar,
            [TabbarItem.name]:TabbarItem,
        },
        methods:{
            goback(){
                this.$router.go(-1);
            },
            onClickLeft(){
                Toast("返回");
            },
            onClickRight(){
                Toast("按钮");
            },
            onLoad(){
                setTimeout(()=>{
                    for(let i=0; i<10; i++){
                        this.list.push(this.list.length + 1);
                    }
                    this.loading = false;
                    if(this.list.length>=40){
                        this.finished = true;
                    }
                },1000);
            }
        }
    }
</script>

<style scoped>
</style>

postcss-px-to-viewport

移动端适配可采用viewport单位,由于viewport单位得到众多浏览器的兼容,flexible的过渡方案可以放弃了。

viewport以vw和vh作为单位,以viewport为基准,其中1vw表示view width的1/100, 1vh表示 view height的1/100。

使用viewport单位需提前设置meta标签中的viewport

<!-- 在 head 标签中添加 meta 标签,并设置 viewport-fit=cover 值 -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, viewport-fit=cover">

postcss-px-to-viewport 会将px单位自动转化为视口单位vw、vh、vmin、vmax的PostCSS插件。

安装组件

$ npm i -S postcss-loader postcss-px-to-viewport

@vue/cli3中由于postcss-px-to-viewport配置项中的exclude选项只能支持正则表达式,因此无法在package.json中进行配置,可配置在vue.config.js文件中。

$ vim vue.config.js
const pxtoviewport = require("postcss-px-to-viewport");
module.exports = {
    css:{
        loaderOptions:{
            postcss:{
                plugins:[
                    pxtoviewport({
                        unitToConvert:"px",
                        unitPrecision:3,
                        viewportWidth:750,
                        viewportUnit:"vw",
                        fontViewportUnit:"vw",
                        minPixelValue:1,
                        mediaQuery:false,
                        replace:true,
                        propList:["*"],
                        selectorBlackList:[],
                        exclude:/(\/|\\)(node_modules)(\/|\\)/
                        landscape:false,
                        landscapeUnit:"vh",
                        landscapeWidth:1334
                    })
                ]
            }
        }
    }
};
配置项 配置值 描述
unitToConvert "px" 需要转换的单位,默认为"px"像素。
viewportWidth 750 设计稿视口宽度,配置后将根据视口做比例换算。
unitPrecison 2 转化进度,转换后保留小数位数。
propList [] 允许转换为vw的属性列表
viewportUnit "vw" 视口单位
fontViewportUnit "vw" 字体使用的视口单位
selectorBlackList [] 需忽略的CSS选择器
minPixelValue 1 最小转换数值
mediaQuery true 媒体查询中的单位是否需要转换
replace true 转换后是否需要添加备用单位
exclude ["node_modules"] 需要忽略的文件夹
landscape false 是否添加根据landscopeWidth生成媒体查询条件@media(orientation:landscape)
landscapeUnit "vh" 横屏时使用的单位
landscapeWidth 1334 横屏时使用的视口宽度

fastclick

移动端开发存在touchstart、touchmove、touchend、touchcancel等事件,而click点击事件执行时浏览器需要等待300毫秒,以判断用户是否再次点击了屏幕。这就造成了很多问题,比如点击穿透等。那么为什么click事件执行时浏览器会等待300ms呢?

2007年苹果为了解决iPhone Safari访问PC页面缩放的问题,提供了一个双击缩放功能。当用户第一次触摸屏幕时浏览器等待300ms才会判断用户是需要click点击还是zoom缩放。这就造成用户触摸屏幕到click点击事件触发存在300ms的延迟。

随着iPhone的成功,后续的无限浏览器复制了其大部分操作系统,其中就包括双击缩放,这也成为主流浏览器的一个功能。虽然300ms延迟在平时浏览网页时并不会带来严重问题,但对于高性能的web app则是一个严重的问题,另外响应式设计的流行也让双击缩放逐渐失去了用武之地。

一般而言,触摸屏幕时间触发流程是这样的:

  1. touchstart
  2. touchmove
  3. touchend
  4. wait 300ms in case of another tap
  5. click

因为这300ms的存在,受到这个延迟影响的场景有:

  • JavaScript监听的click事件
  • 基于click事件交互的元素,比如链接、表单元素。

FastClick是FT Labs专门为解决移动端浏览器300ms点击延迟问题所开发的轻量级库。

使用FastClick时input文本框在iOS设备上点击输入时调取手机自带键盘存在不灵敏,有时甚至无法调起的情况。而在Android上则完全没有问题,这个原因是因为FastClick的点击穿透所带来的。

axios

axios是一个基于promise的HTTP库,可用于浏览器和Node.js环境中。

axios主要特性

  • 从浏览器中创建XMLHttpRequests请求
  • 从Node.js中创建HTTP请求
  • 支持Promise API
  • 支持拦截请求和响应
  • 支持转换请求数据和响应数据
  • 支持取消请求
  • 支持自动转换JSON数据
  • 客户端支持防御XSRF攻击

vue cli 3配置axios插件

$ vue add axios
$ npm ls axios version
$ npm ls axios version
sxyh_web_stats@0.1.0 D:\vue\workspace\sxyh_web_stats
`-- axios@0.18.1

vue cli 3配置中设置代理

如果前端应用和端口API服务器没有运行在同一个主机上,则需在开发环境下将API请求代理到API服务器,此时可通过vue.config.js中的devServer.proxy选项来配置。devServer.proxy使用http-proxy-middleware中间件。

http-proxy-middleware可用于将后台请求转发给其他服务器,比如在当前主机为http://127.0.0.1:3000,浏览器访问当前主机的/api接口,请求的数据确在另一台服务器 http://127.0.0.1:40 上。此时可通过在当前主机上设置代理,将请求转发给数据所在的服务器上。

选项 描述
target 设置目标服务器的主机地址
changeOrigin 是否需要更改原始主机头为目标URL
ws 是否代理websocket
pathRewrite 重写目标URL路径
router 重写指定请求转发目标
$ vim vue.config.js
module.exports = {
    //开发服务器
    devServer:{
        //设置代理
        proxy:{
            "/api":{
                target:"http://127.0.0.1:8080",
                ws:true,
                changeOrigin:true
            }
        }
    }
};

这里由于前端应用使用的是 http://127.0.0.1:8080地址,因此相当于访问前端应用自身。

模拟数据

$ vim public/api/db.json

模拟数据为接口返回的数据,为此需要提前统一规划好数据格式。

{
  "code":200,
  "message":"success",
  "data":[
    "alice", "bob", "carl"
  ]
}

组件中使用axios

由于这里使用MPA多页应用,针对每个单页的入口文件需要单独引入axios。

$ vim src/entry/index.js
import Vue from 'vue';
import Axios  from "axios";

import router from "../router/index.js";
import app from '../pages/index/index.vue';
import layout from "../components/index/layout.vue";

Vue.config.productionTip = false;
Vue.prototype.axios = Axios;

new Vue({
    render:h=>h(app),
    router:router,
    components:{layout},
    template:"<layout/>"
}).$mount('#index');

接在在index.vue组件内使用axios获取db.json中的数据

$ vim src/pages/index/index.vue
<template>
    <div class="page">
        <router-view></router-view>
    </div>
</template>

<script>
    export default {
        name: "index.vue",
        data(){
            return {
                ranklist:[]
            }
        },
        created(){
            this.fetchRanklist();
        },
        methods:{
            fetchRanklist(){
                let self = this;
                this.axios.get("/api/db.json").then(res=>{
                   const data = res.data;
                   console.log(res, data);
                   if(data.code === 200){
                       self.ranklist = data.data;
                   }
                }).catch(err=>{
                    console.error(err);
                });
            }
        }
    }
</script>

<style scoped>

</style>

此时访问 http://127.0.0.1:8080/index 会自动向 http://127.0.0.1:8080/api/db.json 发送请求获取数据。

使用axios返回的数据格式为

{
  config:{...},
  data:{...},
  headers:{...},
  request:...
  status:...,
  statusText:...
}

其中接口返回的数据在data选项中

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容