技术提炼|盘点那些Vue项目中的优秀实践-小程序篇
发表时间:2021-1-5
发布人:葵宇科技
浏览次数:84
这一篇我们来聊聊小程序,这里的小程序开发使用的是uniapp。
cross-env切换环境在pc端的vue项目中,我们通常会使用vue的环境变量来控制程序在不同环境的切换,例如:
目录结构:
├── .env.development
├── .env.test
├── vue.config.js
复制代码
vue.config.js:
module.exports = {
devServer: {
port: 8062,
proxy: {
'/api': {
target: process.env.BASEURL,
pathRewrite: {
'^/api': '/api'
}
}
}
}
}
复制代码
.env.development:
BASEURL=http://developapi.com/
复制代码
.env.test:
BASEURL=http://testapi.com/
复制代码
这样我们只需要通过package.json
里不同环境的script运行,就可以切换到想要的环境。在小程序里,我们通常都是在微信开发工具里编译运行,这个时候我们就可以借助cross-env
和script
来进行环境的切换。
首先,我们需要先安装cross-env
npm install --save-dev cross-env
复制代码
然后我们改写package.json
的script,让我们在运行script的时候额外用node执行一个js文件
目录结构:
├── script
│ └── build.js # 用来修改baseurl的脚本文件
├── utils
│ ├── http.js # 对axios的再次封装,引入config中的url
│ └── config.js # 指定不同环境的baseurl
├── package.json
├── manifest.json
复制代码
package.json:
"scripts": {
"dev": "cross-env NODE_ENV=development node ./script/build.js",
"test": "cross-env NODE_ENV=test node ./script/build.js",
"pro": "cross-env NODE_ENV=production node ./script/build.js"
}
复制代码
build.js:
const fs = require('fs')
const path = require('path')
const manifest = require("../manifest.json")
const config = require("../utils/config.json")
switch (process.env.NODE_ENV) {
case 'development':
manifest["mp-weixin"].appid = 'somewxid1'
config.DEV = 'https://devapi1.com/miniapp/'
config.PRO = 'https://proapi1.com/miniapp/'
break;
case 'test':
manifest["mp-weixin"].appid = 'somewxid2'
config.DEV = 'https://devapi2.com/miniapp/'
config.PRO = 'https://proapi2.com/miniapp/'
break;
case 'production':
manifest["mp-weixin"].appid = 'somewxid3'
config.DEV = 'https://devapi3.com/miniapp/'
config.PRO = 'https://proapi3.com/miniapp/'
break;
}
try {
fs.writeFileSync(path.resolve(__dirname, '../manifest.json'), JSON.stringify(manifest, null, 4))
fs.writeFileSync(path.resolve(__dirname, '../utils/config.json'), JSON.stringify(config, null, 4))
} catch (error) {
console.error(error)
}
console.log('修改成功')
复制代码
config.json:
{
"DEV": "https://devapi1.com/miniapp/",
"PRO": "https://proapi1.com/miniapp/"
}
复制代码
移动端开发中的css在移动端开发中,我们通常会有一份设计规范,这份规范通常会包含一下内容:
- 项目中所使用的字体大小样式及其对应应用场景
- 项目中使用到的颜色及其对应场景
- 项目中一些通用组件的样式
对于这个规范,我们的最佳实践方式是用一个css预编译器文件把这些样式写成常量,在页面中直接取用,例如:
// mixins
.ellipsis(@line: 2) {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: @line;
overflow: hidden;
}
// Color Palette
@primary: #E20000;
@black: #000;
@white: #fff;
@gray-1: #f7f8fa;
@gray-2: #f2f3f5;
@gray-3: #ebedf0;
@gray-4: #dcdee0;
@gray-5: #c8c9cc;
@gray-6: #969799;
@gray-7: #646566;
@gray-8: #323233;
@red: #ee0a24;
@blue: #1989fa;
@orange: #ff976a;
@orange-dark: #ed6a0c;
@orange-light: #fffbe8;
@green: #07c160;
// Gradient Colors
@gradient-red: linear-gradient(to right, #ff6034, #ee0a24);
@gradient-orange: linear-gradient(to right, #ffd01e, #ff8917);
// Component Colors
@text-color: @gray-8;
@active-color: @gray-2;
@active-opacity: .7;
@disabled-opacity: .5;
@background-color: @gray-1;
@background-color-light: #fafafa;
// Padding
@padding-base: 4px;
@padding-xs: @padding-base * 2;
@padding-sm: @padding-base * 3;
@padding-md: @padding-base * 4;
@padding-lg: @padding-base * 6;
@padding-xl: @padding-base * 8;
// Font
@font-size-xs: 10px;
@font-size-sm: 12px;
@font-size-md: 14px;
@font-size-lg: 16px;
@font-weight-bold: 500;
@price-integer-font-family: Avenir-Heavy, PingFang SC, Helvetica Neue, Arial, sans-serif;
// Animation
@animation-duration-base: .3s;
@animation-duration-fast: .2s;
// Border
@border-color: @gray-3;
@border-width-base: 1px;
@border-radius-sm: 2px;
@border-radius-md: 4px;
@border-radius-lg: 8px;
@border-radius-max: 999px;
复制代码
适配不同手机的css显示,无疑是一项绕不开的课题,这里主要想说说如何用好相对布局。
px,em,rem
px:
- 解释:px像素(Pixel)。相对长度单位。像素px是相对于显示器屏幕分辨率而言的。
em:
- 解释:em是相对长度单位。相对于当前对象内文本的字体尺寸。如当前对行内文本的字体尺寸未被人为设置,则相对于浏览器的默认字体尺寸。
- 缺点:em会继承父级元素的字体大小(参考物是父元素的font-size)所以会逐级嵌套计算。
rem:
- 解释:rem是css3中提出来基于em的优化,rem依旧是相对长度单位,但它相对的是根元素。
- 优点:只修改根元素就成比例地调整所有字体大小,避免字体大小逐层复合的连锁反应。
合理的使用px,em,rem可以帮助我们更好的控制大小。在各大小程序里也有诸如rpx
这样小程序提供的相对长度单位可以使用。
百分比和flex
在实现相对布局上,早时候我们最常使用百分比结合行内元素的方法,但这类方法的缺点也十分明显,当我们行内的元素变多,我们需要手动重新计算百分比,动态的增减元素也需要重新计算,所以flex就变得更加受追捧。
有关flex的教程,推荐大家看一看阮大神的这篇博客。
大多数小程序都有分享进行推广的业务场景,虽然小程序自带分享卡片的功能,但因为它的不够直观和相对死板,实际开发中我们更多会使用生成海报分享。对于一些电商小程序,生成的海报还会附带一些额外的功能,例如用户分销上下级绑定,这里我们就来简单介绍一个分享海报的实现。(因为这里我们只专注实现逻辑,所以css的部分就不做展示了)
目录结构:
├── components # 全局公共组件
│ ├── share-popup
│ │ └── share-popup.vue # 选择微信分享或海报分享上拉菜单
│ └── poster-item
│ └── poster-item.vue # 生成海报组件
├── utils
│ └── message.js # 指定分享参数
复制代码
首先我们先来看看share-popup:
<template>
<uni-popup ref="popup">
<div class="icon-content">
<div class="item">
<img class="icon" src="http://www.wxapp-union.com/~@/static/poster.png" @click="clickPoster"/>
<span>生成海报</span>
</div>
<div class="item" @click="close">
<button
class="share-btn"
open-type="share"
:data-title="shareInfo.name"
:data-imgurl="shareInfo.image"
:data-path="shareInfo.path">
<img class="icon" src="http://www.wxapp-union.com/~@/static/wechat_icon.png" />
</button>
<span>分享到微信</span>
</div>
</div>
</uni-popup>
</template>
<script>
export default {
name: 'share-popup',
props: {
shareInfo: {
type: Object,
defalut () {
return {
title: '',
path: '',
image: ''
}
}
}
},
data () {
return {
}
},
computed: {
info () {
return this.$store.state.user.userInfo
}
},
methods: {
clickPoster () {
this.$emit('sharePoster')
this.close()
},
open() {
this.$refs.popup.open()
},
close() {
this.$refs.popup.close()
}
}
}
</script>
复制代码
分享到微信其实就是用了微信button
的开放接口,这里的关键在于我们调用组件时传入的参数。
接下来我们看下poster-item,在这个组件里,我们将会在海报中展示这些信息:
- 用户昵称
- 用户头像
- 设计好的海报背景图
- 小程序分享二维码
- 海报中需要展示的分享详情(这里是商品价格、划线价、名称)
<template>
<uni-popup ref="popup" :maskClick="false" :animation="false">
<div class="flex column">
<div class="btn-close-wrapper">
<div class="btn-close" @click="close"></div>
</div>
<canvas class="canvas-code" canvas-id="canvas" style="width: 300px;height: 452px;">
</canvas>
<div class="btn-save" @click.stop="save">保存到相册</div>
</div>
</uni-popup>
</template>
<script>
const promisify = (fn) => {
return function(args = {}) {
return new Promise((resolve, reject) => {
fn.call(null, {
...args,
success (data) {
console.log('data', data)
resolve(data)
},
fail (err) {
console.log('err', err)
reject(err)
}
})
})
}
}
const downloadFile = promisify(uni.downloadFile)
export default {
name: 'poster-item',
props: {
item: {
type: Object,
defalut () {
return {
realPrice: '0.00',
price: '0.00',
name: '',
image: ''
}
}
},
info: {
page: '',
scene: ''
}
},
data () {
return {
imgSrc: '',
ctx: {}
}
},
computed: {
username () {
return this.$store.state.user.userInfo.nickname
},
avatar () {
const avatar = this.$store.state.user.userInfo.avatar
if (/^http/.test(avatar)) {
return avatar
}
return false
}
},
methods: {
draw ({ realPrice, price, name, username, itemImage, avatar, qrcode }) {
const ctx = this.ctx
ctx.drawImage(itemImage,0, 0, 300, 300);
ctx.setFillStyle('#F0F0F0')
ctx.fillRect(0, 300, 300, 152)
ctx.drawImage(require("@/static/bg.png"), 0, 322, 202, 131);
ctx.font = 'bold 14px "HelveticaNeue-Bold,HelveticaNeue"'
ctx.setFillStyle('#D0021B')
ctx.fillText('¥', 24, 334)
ctx.setFontSize(20)
// realPrice
ctx.fillText(realPrice, 38, 334)
if (realPrice < price) {
const w1 = ctx.measureText(realPrice).width
ctx.font = 'normal 12px "HelveticaNeue"'
ctx.setFillStyle('#4A4A4A')
ctx.fillText(`¥${price}`, 40 + w1, 334)
const w2 = ctx.measureText(`¥${price}`).width
ctx.beginPath()
ctx.moveTo(42 + w1, 330)
ctx.lineTo(42 + w1 + w2, 330)
ctx.stroke()
ctx.closePath()
}
ctx.font = '12px PingFangSC-Regular,PingFang SC'
ctx.setFillStyle('#4A4A4A')
ctx.fillText(name.substring(0, 15), 24, 356, 152)
ctx.fillText(name.substring(15, 30), 24, 372, 152)
ctx.drawImage(qrcode, 205, 322, 67, 67);
ctx.setFillStyle('#4A4A4A')
ctx.fillText('长按识别', 216, 416, 67)
ctx.save()
const self = this
ctx.draw(true, setTimeout((e) => {
uni.canvasToTempFilePath({
canvasId: 'canvas',
success ({ tempFilePath }) {
self.imgSrc = http://www.wxapp-union.com/tempFilePath
}
}, self)
}, 100))
},
open () {
const { realPrice, price, name, image } = this.item
this.$refs.popup.open()
uni.showLoading()
Promise.all([
downloadFile({ url: image }), // 商品图
downloadFile({ url: this.avatar || image }), // 用户头像
// 从后台获取二维码
downloadFile({ url: `someapi/getWxacode?page=${this.info.page}&scene=${encodeURIComponent(this.info.scene)}`, header: {'Authorization': `Bearer ${uni.getStorageSync('token')}` } })
]).then(([
{ tempFilePath: itemImage },
{ tempFilePath: avatar },
{ tempFilePath: qrcode }
]) => {
uni.hideLoading()
this.draw({ realPrice, price, name, username: this.username, itemImage, avatar, qrcode })
})
},
close () {
this.$refs.popup.close()
this.ctx.clearRect(0, 0, 300, 452)
},
save () {
const self = this
uni.showLoading()
uni.saveImageToPhotosAlbum({
filePath: this.imgSrc,
success () {
uni.showToast({ title: '保存成功' })
self.close()
},
complete (res) {
uni.hideLoading()
console.log('complete', res)
}
})
}
},
mounted () {
this.ctx = uni.createCanvasContext('canvas', this)
}
}
</script>
复制代码
这里我们主要是利用了canvas进行海报的绘制。二维码实现用户分销绑定的原理是二维码包含的跳转链接里有参数,在访问这些页面的时候,我们可以提前获取这些参数,完成绑定逻辑。
在完成了这两个组件后,我们会在页面中这样使用:
<template>
<div>
<!-- some other content -->
<button @click="showShare">点击分享</button>
<share-popup ref="share" @sharePoster="showPoster">
</share-popup>
<poster-item
ref="poster"
:item="{
realPrice: product.realPrice,
price: product.price,
name: product.title,
image: product.icons[0]
}"
:info="posterInfo">
</poster-item>
</div>
</template>
<script>
export default {
data () {
return {
product: {}
}
},
computed: {
query () {
return {
id: this.productId,
memberId: this.$store.state.user.userInfo.id
}
},
posterInfo () {
return this.$message.makePoster(this.query)
}
},
async onLoad(query){
// query中包含我们二维码里的参数
// 可以利用query里的值完成绑定
},
onShareAppMessage() {
return {
title: this.product.title,
imageUrl: this.product.icons[0],
path: this.$message.makeShare(this.query)
}
},
methods: {
showPoster () {
this.$refs.poster.open()
},
showShare () {
this.$refs.share.open()
}
}
}
</script>
复制代码
在页面使用的时候,我们其实做了两件事:
- 页面加载的时候获取二维码里携带的信息
- 将需要的信息传递给
poster-item
组件
这里对信息的处理,我们用了$message
方法做了个过滤,下面看看这个方法:
const _dealPath = (path) => {
if (path) {
return path
}
const pages = getCurrentPages()
console.log('_dealPath', pages[pages.length - 1].route)
return pages[pages.length - 1].route
}
const message = {
array: [],
register ({ page, keys }) {
this.array.push({ page, keys})
},
makeShare (params, path) {
path = _dealPath(path)
const index = this.array.findIndex(item => item.page === path)
if (index > -1) {
const { page, keys } = this.array[index]
const query = keys.map(_key => {
return `${_key}=${params[_key]}`
}).join('&')
console.log(`makeShare page: ${page}, query: ${query}`)
return `/${page}?${query}`
}
return ''
},
makePoster (params, path) {
path = _dealPath(path)
const index = this.array.findIndex(item => item.page === path)
if (index > -1) {
const { page, keys } = this.array[index]
const scene = keys.map(_key => {
return params[_key]
}).join('&')
console.log(`makePoster page: ${page}, scene: ${scene}`)
return { page, scene }
}
return null
},
resolveQuery (query) {
const path = _dealPath()
const index = this.array.findIndex(item => item.page === path)
if (index > -1) {
const { keys } = this.array[index]
// 如果是海报
if (query.scene) {
const values = decodeURIComponent(query.scene).split('&')
let res = {}
keys.forEach((key, index) => {
res[key] = values[index]
})
return res
}
// 如果是分享
if (Object.keys(query).length === keys.length) {
return query
}
return false
}
return false
}
}
message.register({ page: 'pages/Search/detail', keys: ['id', 'memberId', 'timestamp'] })
message.register({ page: 'pages/Home/index', keys: ['memberId'] })
export default message
复制代码
这个方法的目的是指定不同的跳转页面生成二维码需要的参数,并进行拼接。使用这个方法的好处在于,以后我们可能会有很多页面需要有生成分享海报的功能,仅仅是每个页面上调用一个拼接参数的函数,会导致我们遗漏或多传递了参数,用这个函数进行过滤可以提前检测我们传递的参数是否正确。
uniapp分包由于小程序有体积和资源加载限制,所以小程序平台提供了分包方式,优化小程序的下载和启动速度。
所谓的主包,即放置默认启动页面/TabBar 页面,以及一些所有分包都需用到公共资源/JS 脚本;而分包则是根据pages.json的配置进行划分。
在小程序启动时,默认会下载主包并启动主包内页面,当用户进入分包内某个页面时,会把对应分包自动下载下来,下载完成后再进行展示。此时终端界面会有等待提示。
注意点:
- subPackages 里的pages的路径是 root 下的相对路径,不是全路径。
- 微信小程序每个分包的大小是2M,总体积一共不能超过16M。
- 百度小程序每个分包的大小是2M,总体积一共不能超过8M。
- 支付宝小程序每个分包的大小是2M,总体积一共不能超过4M。
- QQ小程序每个分包的大小是2M,总体积一共不能超过24M。
- 分包下支持独立的 static 目录,用来对静态资源进行分包。
- 分包是按照分包的顺序进行打包的,所有的subpackages配置以外的文件路径,全部都被打包在主包(App)内。
- subpackages无法嵌入另一个subpackages。
- tabBar页面必须在App主包内。
支持分包的目录结构:
┌─pages
│ ├─index
│ │ └─index.vue
│ └─login
│ └─login.vue
├─pagesA
│ ├─static
│ └─list
│ └─list.vue
├─pagesB
│ ├─static
│ └─detail
│ └─detail.vue
├─static
├─main.js
├─App.vue
├─manifest.json
└─pages.json
复制代码
pages.json:
{
"pages": [{
"path": "pages/index/index",
"style": { ...}
}, {
"path": "pages/login/login",
"style": { ...}
}],
"subPackages": [{
"root": "pagesA",
"pages": [{
"path": "list/list",
"style": { ...}
}]
}, {
"root": "pagesB",
"pages": [{
"path": "detail/detail",
"style": { ...}
}]
}],
// 预加载
"preloadRule": {
"pagesA/list/list": {
"network": "all",
"packages": ["__APP__"]
},
"pagesB/detail/detail": {
"network": "all",
"packages": ["pagesA"]
}
}
}