技术提炼|盘点那些Vue项目中的优秀实践-小程序篇

608 阅读7分钟

上一篇总结了有关控制台的优秀实践,

技术提炼|盘点那些Vue项目中的优秀实践-PC控制台篇

这一篇我们来聊聊小程序,这里的小程序开发使用的是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-envscript来进行环境的切换。

首先,我们需要先安装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的教程,推荐大家看一看阮大神的这篇博客。

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="~@/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="~@/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,在这个组件里,我们将会在海报中展示这些信息:

  1. 用户昵称
  2. 用户头像
  3. 设计好的海报背景图
  4. 小程序分享二维码
  5. 海报中需要展示的分享详情(这里是商品价格、划线价、名称)
<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 = 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"]
    }
  }
}