項目介紹
技術棧
- Vue2.0 (核心框架)
- Vue-CLI 4.0 (Vue腳手架)
- Vue-Router (SPA頁面路由)
- Vuex (狀態管理)
- Axios (網絡請求)
- ES 6 (JavaScript 語言的下一代標準)
- Less (CSS 預處理器)
- Better-Scroll (讓移動端的滾動更為流暢)
- FastClick (解決移動端點擊300ms延遲)
- Vue-Lazyload (懶加載工具)
- PostCss (css代碼轉化工具)
初始化項目
通過 Vue-CLI 4.2.3 創建項目
vue create mall
目錄劃分及相關配置
劃分目錄結構 (父級目錄為 src)
- assets: 創建 img、css 文件夾
- common: 存放一些公共的 JS 文件, 例如公共的常量、方法、工具類
- components: 存放一些公共的組件, 這里還可以分成兩個文件: common 和 content
- common: 存放一些完全公共的組件, 完全獨立的組件內容, 即使存放在下一個項目也能用的組件
- content: 對本項目業務來說是公共的, 存放在下一個項目里時不能使用的組件
- views: 主要存放一些視圖的相關業務和代碼
- router: 存放一些路由相關的代碼
- store: 存放一些 Vuex 公共狀態管理相關的內容
- network: 存放一些網絡相關的代碼
引入兩個初始化 CSS 文件 (父級目錄為 assets/css)
- 初始化 CSS 文件, 讓樣式在各大瀏覽器顯示統一的樣式
- 創建一個 normalize.css 文件, 這里推薦使用 normalize
- 也可以通過
npm install normalize.css
來進行下載
- 創建一個 base.css 文件用來對項目進行統一初始化
- 在這個文件里引用 normalize 文件, 然后再在 App.vue 文件內引入這個文件
base.css 文件
@import './normalize.css';
App.vue 文件
@import './assets/css/base.css';
路徑配置別名
在項目根目錄下創建一個 vue.config.js 配置文件, 到時候會將這個文件和公共配置進行一個合并
module.exports = {
configureWebpack: { // 表明你要配置的是哪個配置文件
resolve: { // resolve 可以解決一些路徑相關的問題
alias: { // 配置別名
// '@': 'src' 默認已經配置了這個別名
'assets': '@/assets',
'common': '@/common',
'components': '@/components',
'network': '@/network',
'store': '@/store',
'views': '@/views'
}
}
}
}
統一代碼風格
在項目根目錄下創建一個 .editorconfig 配置文件, 統一代碼風格
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
路徑問題
上面我們已經為路徑配置了別名, 但在使用時應注意以下幾點:
- 在 JS 中使用可直接使用別名
import 'components/HelloWorld.vue'
- 在含有 src、href 等路徑屬性時需在其別名前加上 ~
<img src='~asstes/logo.png'>
公共組件
制作前要想好組件是否可復用, 是完全公共的組件還是僅項目公共組件
完全公共組件
tabbar : 頁面底部切換組件
navbar : 頂部導航
swiper : 輪播圖
toast : 提示框
scroll : better-scroll 組件
僅項目公共組件
mainTabBar : 使用 tabbar 插槽的組件
tabControl : 分類菜單
backTop : 回到頂部按鈕
goods : 商品展示
tabControl 的下拉吸頂效果
- 獲取到 tabControl 的 offsetTop
- 必須知道滾動到多少時, 開始有吸頂效果, 這個時候就需要獲取距離頂部的距離是多少
- 如果直接獲取到 tabControl 的 offsetTop 的值是不正確的, 因為圖片加載比較慢的原因
- 監聽 HomeSwipper(輪播圖) 中的任意一個 img 的加載完成后發出自定義事件, 在 Home.vue 監聽事件后獲取正確的值
this.$refs.tabControl.$el.offsetTop
- 判斷滾動的距離為元素添加 fixed 樣式
- 但是 better-scroll 是通過改變 translate 來實現滾動的, fixed 樣式依然會被滾到上面, 所以這個方法不管用
- 通過復制一個相同的組件, 放在 better-scroll 外面, 默認隱藏, 當組件重疊的時候顯示, 并設置 層級(z-index) 就可以了
- 這里有一個問題, 兩個組件的點擊事件是不同步的, 要解決這個問題只需要在點擊事件里讓這兩個組件的當前狀態的值一致就可以了
backTop
點擊回到頂部, 這里設置整個組件為點擊事件, 一般情況下直接為組件添加原生事件是不行的, 可以使用修飾符 .native 來實現綁定原生事件
<back-top @click="backClick" /> // 這樣是沒有效果的
<back-top @click.native="backClick" /> // 有效果
使用 better-scroll 對象里的方法 scrollTo(0,0)
來實現回到頁面的頂部
這里直接在滾動組件 Scroll.vue 里封裝了一個 scrollTo 方法
/**
* 設置跳轉位置, 默認跳轉時間300ms
*/
scrollTo(x, y, time = 300) {
this.scroll && this.scroll.scrollTo && this.scroll.scrollTo(x, y, time);
},
點擊事件
<scroll ref="scroll">
滾動的組件
</scroll>
<back-top @click.native="backTop" v-show="isShowBackTop" />
/**
* 回到頂部
*/
backTop() {
this.$refs.scroll.scrollTo(0, 0);
},
/**
* 監聽 better-scroll 的滾動事件
* 1. 顯示/隱藏backTop
* 2. 是否吸頂tabControl
*/
contentScroll(position) {
// 判斷BackTop是否顯示
this.listenerShowBackTop(position.y);
// 決定tabControl是否吸頂(position: fixed)
this.isTabFixed = Math.abs(position.y) >= this.tabOffsetTop;
}
/**
* 顯示/隱藏BackTop
*/
listenerShowBackTop(positionY) {
this.isShowBackTop = Math.abs(positionY) >= BACK_POSITION;
}
this.$refs.scroll
獲取的就是滾動組件里的 scroll 對象, 然后直接調用里面定義的方法就可以了
better-scroll
入門
這里使用的原生的滾動效果, 在手機上使用可能會有延遲感, 卡頓感, 給用戶的體驗并不是很好, 所以推薦使用 Better-Scroll
Better-Scroll 是作用在外層 wrapper 容器上的, 滾動的部分是 content 元素
注意
- wrapper 必須定高, 并且設置
overflow: hidden
- Better-Scroll 只處理容器(wrapper)的第一個子元素(content)的滾動, 其它的元素都會被忽略
某些情況下, 我們希望 wrapper 高度自適應, 例如本項目中 頂部導航欄和底部導航欄高度固定, 中間可滾動區域的 wrapper 高度自適應, 那么可以采取以下方案
/* .scroll-content的父元素 */
#home {
position: relative;
height: 100vh;
}
.scroll-content {
position: absolute;
top: 44px;
bottom: 49px;
left: 0;
right: 0;
overflow: hidden;
}
最簡單的初始化代碼如下
import BScroll from 'better-scroll'
let wrapper = document.querySelector('.wrapper')
let scroll = new BScroll(wrapper)
Better-Scroll 提供了一個類, 實例化的第一個參數是一個原生的 DOM 對象
當然, 如果傳遞的是一個字符串, Better-Scroll 內部會嘗試調用 querySelector 去獲取這個 DOM 對象
如果是在 Vue 中使用, 推薦使用 ref 的方式拿到 DOM 對象, 防止類名相同而拿不到對象
- ref 如果是綁定在組件中的, 那么通過
this.$refs.refname
獲取到的是一個組件對象 - ref 如果是綁定在普通的元素中, 那么通過
this.$refs.refname
獲取到的是一個元素對象
監聽事件
默認情況下 BScroll 是不可以實時的監聽滾動位置, 如果你想監聽滾動, 可以傳遞第二個參數
import BScroll from 'better-scroll'
let wrapper = document.querySelector('.wrapper')
let scroll = new BScroll(wrapper, {
probeType: 3,
pullUpLoad: true,
click: true
})
scroll.on('scroll', (position) => {
console.log(position) // 這里就可以打印監聽的滾動的位置了
})
scroll.on('pullingUp', () => {
console.log('上拉加載更多')
//scroll.finishPullUp()
setTimeout(() => {
scroll.finishPullUp()
}, 2000)
})
probeType : 偵測類型
- 這里可以傳遞的參數有 0 、1 、2 、3
- 0 和 1 都是不偵測實時的位置
- 2 是在手指滾動的過程中偵測, 手指離開后的慣性滾動過程中不偵測
- 3 是只要是滾動都會偵測
pullUpLoad : 監聽滾動到底部事件
- 默認只會觸發一次, 如果想多次觸發, 必須要在每次觸發事件后調用
scroll.finishPullUp()
來結束這次事件, 這樣就可以進行多次監聽滾動到底部事件了 - 如果不想太過頻繁的觸發事件, 可以將調用包裹在一個定時器中
click : 監聽點擊事件
- 如果滑動區域內有除了 button 按鈕以外的點擊事件, 要加上這個才能點擊, 否則點擊事件會失效
- button 按鈕無論該屬性為 true | false 都會生效
封裝
這里用的是 @1.13.2 版本的, 如果是 @2.0 版本以上的要參考官方的方式
在 Vue 中使用的封裝
<template>
<div class="wrapper" ref="wrapper">
<div class="content">
<slot></slot>
</div>
</div>
</template>
<script>
import BScroll from 'better-scroll'
export default {
name: 'Scroll',
props: {
// 由使用者決定偵測類型和是否監聽滾動到底部事件
probeType: {
type: Number,
default: 0
},
pullUpLoad: {
type: Boolean,
default: false
}
},
data() {
return {
scroll: null
}
},
mounted() {
// 創建 BScroll 對象
this.scroll = new BScroll(this.$refs.wrapper, {
click: true,
probeType: this.probeType,
pullUpLoad: this.pullUpLoad
});
// 監聽滾動的位置
if (this.probeType == 2 || this.probeType == 3) {
this.scroll.on("scroll", position => {
this.$emit("scroll", position);
})
}
// 監聽scroll滾動到底部
if (this.pullUpLoad) {
this.scroll.on("pullingUp", () => {
this.$emit("pullingUp");
})
}
},
methods: {
/**
* 設置跳轉位置
*/
scrollTo(x, y, time = 300) {
this.scroll && this.scroll.scrollTo && this.scroll.scrollTo(x, y, time);
},
/**
* 刷新底部上拉事件
*/
finishPullUp() {
this.scroll && this.scroll.finishPullUp && this.scroll.finishPullUp();
},
/**
* 刷新scroll可滾動高度
*/
refresh() {
this.scroll && this.scroll.refresh && this.scroll.refresh();
},
/**
* 獲取當前scroll的y值
*/
getScrollY() {
return this.scroll.y ? this.scroll.y : 0;
}
}
}
</script>
使用
使用時將封裝好的組件導入, 并將要滑動的區域用標簽包裹起來
<scroll
class="scroll-content"
ref="scroll"
:probe-type="3"
:pull-up-load="true"
@scroll="contentScroll"
@pullingUp="loadMore">
<div>
需要包裹的內容
</div>
</scroll>
better-scroll 有時不能滾動 bug
better-scroll 對象的 scrollerHeight 方法里面記錄了可滾動內容的高度, 這個屬性是根據放在 content 中的子組件的高度來決定的, 但是在剛開始計算 scrollerHeight 屬性時, 由于圖片加載比較慢, 所以沒有將圖片高度計算在內, 所以得到的可滾動高度是錯誤的, 后面圖片加載進來之后高度被撐開了, 但是 scrollerHeight 屬性并沒有進行更新, 所以滾動出現了問題
解決方案:
監聽每一張圖片是否加載完成, 只要有一張圖片加載完成, 就執行一次 refresh()
- 原生的 JS 監聽圖片加載完成的方式:
img.onload = function() {}
- Vue 中監聽:
@load=imageLoad
, 這里是非父子組件通信- 通過 Vuex 傳遞方法
- 通過 事件總線 $bus 的方式
- 因為有多個頁面都用到 better-scroll, 為了方便管理, 這里使用事件總線 $bus 的方式傳遞方法
- 在 (main.js) Vue 原型上添加 $bus
Vue.prototype.$bus = new Vue()
- 將方法發送到 $bus 中
imageLoad() { this.$bus.$emit('itemImageLoad') }
- 通過 $bus 監聽圖片加載完成, 并調用 refresh
this.$bus.$on('itemImageLoad', () => { 調用refresh })
$bus 取消事件監聽
this.$bus.$off('方法名', '對應的處理函數')
防抖
每張圖片加載完之后都會立刻調用一次 refresh, 這對于性能上來說無異于是負擔, 所以, 通過防抖對性能進行優化
/**
* 防抖
*/
function debounce(func, delay = 100) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
func && func.apply(this, args);
}, delay);
}
}
解決移動端 URL 欄 和 底部工具欄 顯示/隱藏 時高度 Bug
Bug 原因
移動端下瀏覽器對 100vh 的定義不考慮 URL 欄 和 底部工具欄 的高度(無論顯示還是隱藏), 可以用下面這張圖直觀地體現問題
當地址欄可見時, 由于移動瀏覽器不正確地將 100vh 設置為屏幕高度而沒有顯示地址欄, 因此屏幕底部被切斷
在上圖中, 應該在屏幕底部的按鈕被隱藏了
更糟糕的是, 當用戶第一次使用手機訪問網站時, 地址欄會顯示在頁面頂部, 因此用戶體驗是很糟糕的
設置 home 高度也不能直接使用 100%, 因為 100% 是相對與父元素, 而 home 的父元素的高度又沒有固定, 而是依賴與 home 的高度撐開, 所以百分比無效
解決方案 (window.innerHeight)
解決這個問題的一種方法是依賴 JavaScript 而不是 CSS, 當頁面加載時, 將高度設置為 window.innerHeight
將正確地將高度設置為窗口的可見部分
使用 window.innerHeight
動態設置高度
當窗口大小改變時重新設置高度為 window.innerHeight
, 因為 window.innerHeight
的高度不包括地址欄和工具欄
- 如果地址欄是可見的, 那么
window.innerHeight
將是屏幕可見部分的高度, 正如你所期望的那樣 - 如果地址欄是隱藏的, 那么
window.innerHeight
是全屏的高度
<template>
<div id="home" :style="{ height: homeHeight }"><div>
</template>
<script>
export default {
data() {
return {
homeHeight: window.innerHeight + 'px'
}
}
mounted() {
window.addEventListener("resize", () => {
this.homeHeight = window.innerHeight + "px"
})
}
}
</script>
<style>
#home {
position: relative;
/* height: 100vh */
}
</style>
讓 Home 不銷毀(destroyed), 并在路由來回切換后回到離開時的位置
讓 home 不要隨意銷毀掉
添加 keep-alive 就可以了
讓 home 中的內容保持原來的位置
data() {
return {
saveY: 0
}
},
activated() {
// 當路由處于活躍狀態時, 將頁面回到離開時的位置, 且刷新一次 scroll 的高度
this.$refs.scroll.scrollTo(0, this.saveY, 0)
this.$refs.scroll.refresh()
},
deactivated() {
// 當路由處于不活躍狀態時, 保存 scroll 的 y 值
this.saveY = this.$refs.scroll.getScrollY()
// 取消該路由的圖片加載事件監聽
this.$bus.$off("itemImageLoad", this.itemImageListener);
}
詳情頁
this.$nextTick(() => {})
在 created 中這個函數意思是: 等模板渲染完后就執行這個函數, 從這里就可以拿到一些數據, 這個時候對應的 DOM 已經報備渲染出來了, 但是圖片依然是沒有加載完
一定要將詳情頁銷毀
<keep-alive exclude="Detail">
<router-view />
</keep-alive>
如何判斷一個對象是不是一個空的對象
const obj = {}
Object.keys(obj).length === 0
混入(mixin)的使用
創建混入對象: const mixin = {}
組件中導入: mixins: [mixin]
點擊標題,滾動到對應的主題
- 獲取標題的 offsetTop
- 在哪里才能獲取到正確的 offsetTop ?
- created 肯定不行, DOM 還沒渲染
- mounted 也不行, 圖片數據還沒有加載完
- nextTick 也不行, 雖然 DOM 改變觸發 nextTick 鉤子, 但圖片不一定加載完, 導致offsetTop是錯誤的值
方案一
在 created 中事先通過防抖獲得處理函數, 等待圖片加載完畢之后再調用該函數
created() {
/**
* 通過防抖獲得 getThemeTopY 函數, 等待圖片加載完之后再調用
*/
this.getThemeTopY = debounce(() => {
this.$nextTick(() => {
this.themeTopYs = [];
this.themeTopYs.push(0);
this.themeTopYs.push(this.$refs.params.$el.offsetTop);
this.themeTopYs.push(this.$refs.comment.$el.offsetTop);
this.themeTopYs.push(this.$refs.recommend.$el.offsetTop);
})
}, 100);
},
methods: {
/**
* 刷新scroll高度, 且獲得各個標題的 offsetTop
*/
detailImageLoad() {
this.refresh();
this.getThemeTopY();
}
}
方案二
等待所有圖片加載完畢
methods: {
detailImageLoad() {
// 判斷所有的圖片都加載完了, 進行一次回調
if (++this.counter === this.imageLength) {
this.refresh();
this.$emit("detailImageLoad");
}
}
}
vuex
mutations 唯一的目的就是修改 state 中狀態, 最好是其中的每個方法盡可能完成得事件比較單一一點, 否則每次執行的時候執行的方法名字一樣, 不知道到底執行的是哪個
如果有邏輯判斷推薦放到 actions 里, 執行的方法可以放到 mutations 里, 這樣就可以跟蹤每個想要調試的點
const store = new Vuex.Store({
state: {
cartList: []
},
mutations: {
addCount(state, payload) {
payload.count++
},
addToCart(state, payload) {
state.cartList.unshift(payload)
}
},
actions: {
addToCart({ state, commit }, payload) {
return new Promise((resolve, reject) => {
let oldProduct = state.cartList.find(item => item.iid === payload.iid)
if (oldProduct) {
commit('addCount', oldProduct)
resolve("當前商品已被添加到購物車+1")
} else {
payload.count = 1
payload.checked = true
commit('addToCart', payload)
resolve("已添加至購物車")
}
})
}
}
})
目錄結構
建議分類成一個一個的文件, 這樣方便管理, 還可以封裝常量文件
index.js
import Vue from "vue";
import Vuex from "vuex";
import mutations from "./mutations";
import actions from "./actions";
import getters from "./getters"
Vue.use(Vuex);
const state = {
cartList: []
}
export default new Vuex.Store({
state,
mutations,
actions,
getters
})
toast 插件封裝
在 components/common/toast 文件夾下新建兩個文件
- index.js
- Toast.vue
index.js
import Toast from "./Toast.vue"
export default {
install(Vue) {
const toastConstructor = Vue.extend(Toast);
const toast = new toastConstructor();
toast.$mount(document.createElement("div"));
document.body.appendChild(toast.$el);
Vue.prototype.$toast = toast;
}
}
Toast.vue
<template>
<div v-show="isShow" class="toast">
<div>{{message}}</div>
</div>
</template>
<script>
export default {
name: "Toast",
data() {
return {
message: "",
isShow: false
}
},
methods: {
show(message, duration = 2000) {
this.isShow = true;
this.message = message;
setTimeout(() => {
this.isShow = false;
this.message = "";
}, duration);
}
}
};
</script>
<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
.toast {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, .7);
padding: 8px 10px;
color: #fff;
text-align: center;
border-radius: 8px;
z-index: 9999;
}
</style>
main.js
import toast from './components/common/toast/index';
Vue.use(toast) // 這里會去執行 index.js 里的 install 方法
使用的時候, 只需要: this.$toast.show("需要顯示的文字", 2000)
就可以了
細節處理
FastClick
使用 FastClick 解決移動端點擊 300ms 的延遲
安裝
npm install fastclick --save
使用 (在 main.js 中安裝插件)
import FastClick from 'fastclick'
FastClick.attach(document.body)
圖片懶加載
圖片需要顯示在屏幕上時再加載
安裝
npm install vue-lazyload --save
使用 (在 main.js 中安裝插件)
import VueLazyLoad from 'vue-lazyload'
Vue.use(VueLazyLoad, {
// 顯示占位圖
loading: require('./assets/img/common/placeholder.jpg')
})
// 修改組件中 img 的屬性 :src => v-lazy
快捷修改 CSS 單位(適配不同設備)
項目直接是使用的 px 單位進行開發的, 這里改成 vm 單位
使用插件, 有很多類似的插件, 這里使用的 postcss-px-to-viewport, 這是開發時依賴
安裝
npm install postcss-px-to-viewport --save-dev
配置(在項目根目錄下創建 postcss.config.js 配置文件)
module.exports = {
plugins: {
autoprefixer: {},
"postcss-px-to-viewport": {
viewportWidth: 375, // 視口寬度, 對應的是設計稿寬度
viewportHeight: 667, // 視口高度, 對應的是設計稿的高度
unitPrecision: 5, // 指定'px'轉換為視口單位值的小數位數(保留5位小數)
viewportUnit: "vw", // 指定需要轉換成的視口單位, 建議使用vw
selectorBlackList: ["ignore"], // 指定不需要轉換的類
minPixelValue: 1, // 小于或等于'1px'不轉換為視口單位
mediaQuery: false, // 允許在媒體查詢中轉換'px'
exclude: [/TabMenu\.vue/] // 排除文件名包含 TabBar 的文件,必須是正則來匹配文件
}
}
}
這樣項目中所有的 px 單位就會變成 vm 單位
項目部署到遠程服務器
使用 webpack 打包項目
npm run build
使用服務器軟件: tomcat、nginx, 這里使用 nginx
將 build 文件中的所有文件、文件夾、圖片拷貝到站點根目錄下
刷新頁面 404
問題
將項目部署到遠程服務器上后, 在頁面中一旦刷新, 會出現 404
原因
使用 history 模式時, 還需要后臺配置支持
因為我們的應用是個單頁客戶端應用, 如果后臺沒有正確的配置, 當直接訪問 http://mall.coderlion.com/home 就會報 404 的錯誤
所以需要在服務端增加一個覆蓋所有情況的候選資源: 如果 URL 匹配不到任何靜態資源, 則應該返回同一個 index.html 頁面, 這個頁面就是 home 頁面
解決方案
為 nginx 服務器添加重定向配置
location / {
try_files $uri $uri/ /index.html;
}
其他服務器配置參照官方文檔
Vue 響應式原理
- 當數據發生修改時, Vue 內部是如何監聽 message 數據的改變
- Object.defineProperty -> 監聽對象屬性的改變
- 當數據發生改變, Vue 是如何知道要通知那些人, 界面發生刷新
- 發布訂閱者模式