如何在APICloud项目中使用Vue.js组件化开发

目录

上一篇文章中,我建议大家根据APICloud官方使用原生JS开发的建议,在不影响项目结构和性能的情况下,将Vue.js以script引用的方式直接作用于页面上,使用最基本的vue.js逻辑来做全局或者局部的页面渲染

虽然直接通过script引入的方式也可以正常使用vue.js,但这样也存在许多不便

例如:

  • 无法解决低版本手机不完全支持ES6及以上语法的问题
  • 使用js文件声明vue组件时拼接字符串繁琐不易维护
  • 无法直接引用npm生态下优秀的前端组件库

一般来说,大型团队很少有直接使用APICloud进行开发的先例。而多数APICloud开发者为中小型或者创业团队,团队资金和人手相对不那么富余,因此在能避免重复性工作或不易维护的代码亦或者重复造轮子的地方就应当尽量避免。

本文将提供一种思路以解决APICloud的Vue组件化开发的问题。

示例项目操作环境如下:

平台/工具说明
操作系统Mac OS 10.13.6 (17G65)
浏览器Chrome 77.0.3865.120
nvm下nodejs版本10.13.0
编辑器Visual Studio Code 1.39.2

一、基本思路

APICloud官方论坛及其它社区环境中,经常可以看到有小伙伴尝试使用vue-cli创建出包括vuexvue-router在内的单页面项目,通过build之后将dist下的单页面内容绑定到单个html上,然后将这个单页html文件和相关js文件上传到APICloud中。这种做法虽然能够正常运行,但是却背离了APICloud平台最初的Hybrid App的初衷,从而直接变成了一个纯粹的h5项目。

在移动端设备下,使用vue-router配合h5模拟出一个页面切换的效果,其效率及流畅度或用户体验是远远不及APICloud中openWinopenFrame这类调用原生接口实现窗口管理的效果的。

通常的vue-cli项目是基于webpack构建的,已经有了默认配置。其默认配置中入口只有一个,反观APICloud项目的基本结构,每一个html页面都需要引入相关文件且页面之间的依赖引用不会相互冲突,因此每个窗口页面都可以理解为是一个独立的入口

在这个基本思路下,我们可以提出一个假设:将vue项目打包成与APICloud项目一模一样的结构。

结合以上假设及APICloud项目开发经验,我们可以得出以下几个需求点:

  • 组件化开发,支持.vue文件
  • vue项目打包后必须是多页面入口
  • 打包后的项目要能正常使用APICloud的api
  • vue项目至少使用一个组件库并且保证打包后能够正常运行于APICloud中
  • html、css、js分别使用独立目录统一管理

根据以上几个需求点,我们可以得出大致流程:

  1. 初始化项目
  2. 构建基础项目结构
  3. 编写测试页面
  4. 配置webpack
  5. 编译测试

其中最重要的一步就是配置webpack

二、初始化项目

初始化一个新的项目apicloud-vue

mkdir apicloud-vue
cd apicloud-vue
npm init

所有选项默认回车选中,得到一个基本的package.json

{
  "name": "apicloud-vue",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "author": "",
  "license": "ISC"
}

此时我们安装需要用到的依赖或者直接修改package.json,这里直接贴上package.json的配置:

{
  "name": "apicloud-vue",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "webpack --config webpack/webpack.build.js"
  },
  "dependencies": {
    "@babel/polyfill": "^7.4.0",
    "@babel/runtime": "^7.4.2",
    "vant": "^2.2.7",
    "vue": "^2.6.10"
  },
  "devDependencies": {
    "@babel/core": "^7.2.2",
    "@babel/plugin-transform-runtime": "^7.2.0",
    "@babel/preset-env": "^7.3.1",
    "babel-loader": "^8.0.5",
    "babel-plugin-import": "^1.11.0",
    "clean-webpack-plugin": "^2.0.1",
    "copy-webpack-plugin": "^5.0.2",
    "css-loader": "^2.1.0",
    "cssnano": "^4.1.10",
    "html-webpack-plugin": "^3.2.0",
    "less": "^3.10.3",
    "less-loader": "^5.0.0",
    "mini-css-extract-plugin": "^0.8.0",
    "optimize-css-assets-webpack-plugin": "^5.0.3",
    "require-context": "^1.1.0",
    "style-loader": "^0.23.1",
    "vue-loader": "^15.5.1",
    "vue-template-compiler": "^2.6.10",
    "webpack": "^4.29.0",
    "webpack-cli": "^3.2.1",
    "webpack-merge": "^4.2.1"
  }
}

创建基本项目目录并创建示例页面:

  • src/components [目录] 公共组件
  • src/libs [目录] js工具库
  • src/libs/apicloud.js [文件] APICloud默认项目中的api.js,根据需求决定是否使用
  • src/pages [目录] 页面文件
  • src/templates [目录] 编译模资源
  • src/templates/global.css [文件] 全局样式
  • src/templates/page.ejs [文件] 页面模板
  • babel.config.js [文件] babel配置文件

全局样式文件取决于项目需要,这里可要可不要。在这里默认为css格式,需要其它格式的请自行修改配置webpack。

src/templates/global.css:

html,body {
  -webkit-touch-callout: none;
  -webkit-text-size-adjust: none;
  -webkit-tap-highlight-color: rgba(0, 0, 0, 0);
  -webkit-user-select: none;
  background-color: rgba(255, 255, 255, 1);
}

babel.config.js:

module.exports = {
  presets: ['@babel/preset-env'],
};

对于page.ejs模板页来说,这里与普通vue项目不同的是,普通项目直接export default { xxx }将页面组件化导出。而现在则需要将每个页面的stylehtmlscript分别提取成对应名称的文件,提取后的样式通过link引入模板页,脚本通过script引入模板页,而模板页如何获取到脚本内容呢,这里可以通过将vue脚本内容绑定到window对象进行中转取值。

这里无需给每个页面绑定到window的不同attribute上,因为APICloud的页面管理系统中,每个窗口的window对象都是独立的,因此可以统一命名成相同的名称,如$page。

src/templates/page.ejs:

<!doctype html>
<html>

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="maximum-scale=1.0,minimum-scale=1.0,user-scalable=0,width=device-width,initial-scale=1.0,viewport-fit=cover"/>
  <meta name="format-detection" content="telephone=no,email=no,date=no,address=no">
  <title><%= htmlWebpackPlugin.options.name %></title>
  <link href="<%= htmlWebpackPlugin.options.pathFixed %>/css/global.css" rel="stylesheet" type="text/css">
</head>

<body>
  <div id="vue"></div>
  <script type="text/javascript" src="<%= htmlWebpackPlugin.options.pathFixed %>/script/vue.<%= htmlWebpackPlugin.options.env == 'production' ? 'min.js' : 'js' %>"></script>
  <script type="text/javascript">
    var page
    apiready = function () {
      if (Vue) {
        Vue.prototype.$ac = api;
        var vuepage = window.$page || {};
        vuepage.el = '#vue';
        page = new Vue(vuepage);
      }
    }
  </script>
</body>

</html>

src/components/header.vue:

<template>
  <nav-bar id="nav" :title="title || '标题'" :left-arrow="left" @click-left="clickLeft" fixed>
    <slot name="right" slot="right"></slot>
  </nav-bar>
</template>

<script>
import { NavBar } from 'vant';
import apicloud from '@/libs/apicloud';

export default {
  name: 'navbar',
  components: { NavBar },
  props: ['title', 'left', 'right'],
  mounted() {
    apicloud.fixStatusBar(apicloud.dom('#nav'));
  },
  methods: {
    clickLeft() {
      api.closeWin();
    },
  },
};
</script>

<style scoped>
.van-nav-bar {
  height: auto !important;
}
</style>

src/pages/index.vue:

<template>
  <div>
    <Header :left="false" />
    <Tabbar v-model="tabActive" id="tabbar" @change="changeTab" fixed>
      <TabbarItem icon="home-o">页面一</TabbarItem>
      <TabbarItem icon="search" dot>页面二</TabbarItem>
    </Tabbar>
  </div>
</template>

<script>
import Header from '@/components/header.vue';
import apicloud from '@/libs/apicloud';
import { Tabbar, TabbarItem } from 'vant';

export default (window.$page = {
  components: { Header, Tabbar, TabbarItem },
  data() {
    return {
      tabActive: 0,
    };
  },
  created() {
    let curTime = 0;
    api.addEventListener({name: 'keyback', }, (ret, err) => {
      let curSecond = new Date().getSeconds();
      if (Math.abs(curSecond - curTime) > 2) {
        curTime = curSecond;
        api.toast({
          msg: '再按一次退出程序',
          duration: 2000,
          location: 'bottom',
        });
      } else {
        api.closeWidget({ silent: true });
      }
    });
  },
  mounted() {
    let navHeight = apicloud.dom('#nav').offsetHeight;
    let tabbarHeight = apicloud.dom('#tabbar').offsetHeight;

    api.openFrameGroup({
      name: 'homeTabs',
      scrollEnabled: true,
      rect: {
        w: 'auto',
        marginTop: navHeight,
        marginBottom: tabbarHeight,
      },
      index: 0,
      useWKWebView: true,
      historyGestureEnabled: true,
      frames: [
        { name: 'tab1',
          url: 'tabbar/tab1.html',
        },
        { name: 'tab2',
          url: 'tabbar/tab2.html',
          bounces: true,
        },
      ],
    }, (ret, err) => {
      this.tabActive = ret.index;
    });
  },
  methods: {
    changeTab(index) {
      api.setFrameGroupIndex({
        name: 'homeTabs',
        index,
        scroll: true,
      });
    },
  },
});
</script>

<style scoped>
#tabbar {
  position: fixed;
}
</style>

src/pages/tabbar/tab1.vue:

<template>
  <PullRefresh v-model="isLoading" @refresh="onRefresh">
    <Search placeholder="搜索" />
    <List v-model="loading" :finished="finished" finished-text="- 暂无更多 -" @load="loadMore">
      <Cell v-for="item in list" :key="item" :title="item" />
    </List>
  </PullRefresh>
</template>

<script>
import { Search, List, Cell, PullRefresh, Toast } from 'vant';

export default (window.$page = {
  components: { Search, List, Cell, PullRefresh },
  data() {
    return {
      list: [],
      isLoading: false,
      loading: false,
      finished: false,
    };
  },
  methods: {
    onRefresh() {
      setTimeout(() => {
        Toast('刷新成功');
        this.isLoading = false;
      }, 500);
    },
    loadMore() {
      // 异步更新数据
      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;
        }
      }, 500);
    },
  },
});
</script>

src/pages/tabbar/tab2.vue:

<template>
  <div>
    <DropdownMenu>
      <DropdownItem v-model="value1" :options="option1" />
      <DropdownItem v-model="value2" :options="option2" />
    </DropdownMenu>
    <br />
    <Button type="warning" @click="openView">打开子页面</Button>
    <br /><br />
    <Tabs v-model="active">
      <Tab title="标签 1"></Tab>
      <Tab title="标签 2"></Tab>
      <Tab title="标签 3"></Tab>
      <Tab title="标签 4"></Tab>
    </Tabs>
    <br />
    <Button type="info" @click="openDialog">提示</Button>
    <Button type="danger" @click="openConfirm">确认</Button>
    <br />
    <CountDown :time="time"></CountDown>
    <br />
    <Divider>分隔线</Divider>
    <br />
    <Skeleton title :row="3" />
    <br />
    <Button @click="show = true">弹出层</Button>
    <Popup v-model="show" position="top" :style="{ height: '20%' }"></Popup>
  </div>
</template>
<script>
import { Icon, CellGroup, Cell, Button, Tab, Tabs, Dialog, DropdownMenu, DropdownItem, CountDown, Divider, Skeleton, Popup } from 'vant';

export default (window.$page = {
  components: { Icon, CellGroup, Cell, Button, Tab, Tabs, Dialog, DropdownMenu, DropdownItem, CountDown, Divider, Skeleton, Popup },
  data() {
    return {
      time: 30 * 60 * 60 * 1000,
      active: 2,
      value1: 0,
      value2: 'a',
      option1: [{ text: '全部商品', value: 0 }, { text: '新款商品', value: 1 }, { text: '活动商品', value: 2 }],
      option2: [{ text: '默认排序', value: 'a' }, { text: '好评排序', value: 'b' }, { text: '销量排序', value: 'c' }],
      show: false,
    };
  },
  methods: {
    openView() {
      api.openWin({
        name: 'view',
        url: '../view/view.html',
      });
    },
    openDialog() {
      Dialog({ message: '提示' });
    },
    openConfirm() {
      Dialog.confirm({
        title: '标题',
        message: '弹窗内容',
      })
        .then(() => {})
        .catch(() => {});
    },
  },
});
</script>

view.vue这里相当于APICloud中的推荐格式,先打开一个window,然后在此window中再打开一个独立的frame,这里相当于是window。

src/pages/view/view.vue:

<template>
  <Header title="详情" :left="true">
    <span slot="right">按钮</span>
  </Header>
</template>

<script>
import Header from '@/components/header.vue';
import apicloud from '@/libs/apicloud';

export default (window.$page = {
  components: { Header },
  data() {
    return {};
  },
  mounted() {
    let navHeight = apicloud.dom('#nav').offsetHeight;
    api.openFrame({
      name: 'view_frm',
      url: 'viewFrm.html',
      rect: {
        marginTop: navHeight,
        marginBottom: 0,
        w: 'auto',
      },
    });
  },
  methods: {},
});
</script>

viewFrm.vue是window下的frame。

src/pages/view/viewFrm.vue:

<template>
  <div>
    <NoticeBar text="APICloud + Vue + Webpack + Vant APICloud + Vue + Webpack + Vant" left-icon="volume-o" />
    <br />
    <Button plain type="info" @click="show = true">时间选择弹框</Button>
    <br />
    <Popup v-model="show" position="bottom">
      <DatetimePicker v-model="currentDate" type="datetime" :min-date="minDate" :max-date="maxDate" :formatter="formatter" />
    </Popup>
    <br />
    <van-circle v-model="currentRate" color="#07c160" fill="#fff" size="120px" layer-color="#ebedf0" :text="text" :rate="60" :speed="100" :clockwise="false" :stroke-width="60" />
    <van-circle v-model="currentRate" :text="text" :rate="40" :speed="100" :clockwise="false" :stroke-width="80" />
    <br />
    <br />
    <!-- 密码输入框 -->
    <PasswordInput :value="value" info="密码为 6 位数字" @focus="showKeyboard = true" />
    <br />
    <Stepper v-model="stepperValue" />
    <br />
    <Slider v-model="sliderValue" />
    <!-- 数字键盘 -->
    <NumberKeyboard :show="showKeyboard" @input="onInput" @delete="onDelete" @blur="showKeyboard = false" safe-area-inset-bottom />
  </div>
</template>

<script>
import { Picker, Toast, ImagePreview, DatetimePicker, Popup, Icon, CellGroup, Cell, Button, Circle, NoticeBar, PasswordInput, NumberKeyboard, Stepper, Slider } from 'vant';

export default (window.$page = {
  components: { Picker, Toast, ImagePreview, DatetimePicker, Popup, Icon, CellGroup, Cell, Button, VanCircle: Circle, NoticeBar, PasswordInput, NumberKeyboard, Stepper, Slider },
  data() {
    return {
      minDate: new Date(),
      maxDate: new Date(2019, 10, 1),
      currentDate: new Date(),
      show: false,
      currentRate: 42,
      value: '123',
      stepperValue: 7,
      showKeyboard: false,
      sliderValue: 20,
    };
  },
  computed: {
    text() {
      return this.currentRate.toFixed(0) + '%';
    },
  },
  methods: {
    onInput(key) {
      this.value = (this.value + key).slice(0, 6);
    },
    onDelete() {
      this.value = this.value.slice(0, this.value.length - 1);
    },
    formatter(type, value) {
      if (type === 'year') {
        return `${value}年`;
      } else if (type === 'month') {
        return `${value}月`;
      } else if (type === 'day') {
        return `${value}日`;
      } else if (type === 'hour') {
        return `${value}时`;
      } else if (type === 'minute') {
        return `${value}分`;
      }
      return value;
    },
  },
});
</script>

三、配置Webpack

在根目录下创建webpack目录并新建webpack.base.jswebpack.build.js文件。

webpack/webpack.base.js:

const VueLoaderPlugin = require('vue-loader/lib/plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const OptimizeCssAssetsPlugin = require('optimize-css-assets-webpack-plugin');
const resolve = require('path').resolve;

const scriptPath = 'script'; // 输出js文件存放目录
const cssPath = 'css'; // 输出css文件存放目录

module.exports = {
  output: {
    path: resolve(__dirname, '../dist'),
    filename: `${scriptPath}/[name].js`,
  },
  module: {
    rules: [
      {
        test: /\.vue$/,
        loader: 'vue-loader',
      },
      {
        test: /\.css$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader'],
      },
      {
        test: /\.less$/,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'less-loader'],
      },
      {
        test: /\.js$/,
        loader: 'babel-loader',
        options: { cacheDirectory: true },
        exclude: file => /node_modules/.test(file) && !/\.vue\.js/.test(file),
      },
    ],
  },
  plugins: [
    new MiniCssExtractPlugin({
      filename: `${cssPath}/[name].css`,
    }),
    new VueLoaderPlugin(),
    new CopyWebpackPlugin([{ from: './src/templates/global.css', to: `./${cssPath}` }, { from: './src/templates/config.xml', to: './' }]),
    new OptimizeCssAssetsPlugin({
      assetNameRegExp: /\.css$/g,
      cssProcessor: require('cssnano'),
      cssProcessorPluginOptions: {
        preset: ['default', { discardComments: { removeAll: true } }],
      },
      canPrint: true,
    }),
  ],
  externals: {
    vue: 'Vue',
  },
  resolve: {
    alias: {
      '@': resolve('./src'),
    },
  },
};

webpack/webpack.build.js:

const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const base = require('./webpack.base');
const path = require('path');
const requireContext = require('require-context');

const input_path_pages = path.resolve(__dirname, '../src/pages');
const output_path_script = 'script'; // 输出js文件目录
const output_path_html = 'html'; // 输出html文件目录

const pagesFiles = requireContext(path.resolve(__dirname, input_path_pages), true, /\.vue/);

let entry = {}; // 入口配置对象
let htmlWebpacks = [];
const minify = {
  minimize: true, // 压缩代码
  removeComments: true, // 移除注释
  collapseWhitespace: true, // 删除空格、换行
  minifyCSS: true, // 压缩行内css
  minifyJS: true, // 压缩行内js
};

pagesFiles.keys().forEach(path => {
  const fullPath = `${input_path_pages}/${path}`;
  const pathName = path.replace(/\\/g, '/').replace(/.vue/gi, '');
  const name = path.replace(/\\/g, '/').replace(/(.*\/)*([^.]+).*/gi, '$2');
  // 保留页面原始目录结构
  entry[name] = fullPath;
  // 页面相对路径校正
  let pathFixed = '';
  for (var i = 1; i <= pathName.split('/').length; i++) {
    pathFixed = `${pathFixed}../`;
  }
  htmlWebpacks.push(
    new HtmlWebpackPlugin({
      env: 'production',
      name,
      pathFixed: pathFixed.replace(/^(\s|\/)+|(\s|\/)+$/g, ''),
      vuejs: 'vue.min.js',
      filename: `${output_path_html}/${pathName}.html`,
      chunks: [name, 'runtime'],
      template: './src/templates/page.ejs',
      minify,
    }),
  );
});

module.exports = merge(base, {
  mode: 'production',
  entry,
  optimization: {
    runtimeChunk: {
      name: 'runtime',
    },
    splitChunks: {
      chunks: 'async',
    },
  },
  plugins: [new CleanWebpackPlugin(), new CopyWebpackPlugin([{ from: './src/templates/vue.min.js', to: `./${output_path_script}` }]), ...htmlWebpacks],
});

四、配置Vant

本文中使用到的组件库是有赞的Vant,能满足大部分的页面构建需求。

根据Vant 安装说明的推荐,我们需要修改babel.config.js文件增加vant配置。

vant支持修改主题以满足不同项目的视觉需求,可以参考定制主题,这里不再详述。

babel.config.js:

module.exports = {
  presets: ['@babel/preset-env'],
  plugins: [
    '@babel/plugin-transform-runtime',
    ['import', {
      libraryName: 'vant',
      libraryDirectory: 'es',
      style: true
    }, 'vant'],
  ],
};

Vant使用了Less对样式进行预处理,所需的less-loader配置已集成在上一步的配置Webpack中。

五、测试编译

最终的项目结构如图所示:
在这里插入图片描述

其中config.xml是APICloud项目的配置文件,可以替换成你自己的配置。vue.min.js的版本为2.6.10。

此时便可直接进行编译了:

npm run build

可以看到,编译之后的输出结果在dist目录下,其中的html目录与编译之前的pages目录结构相同。

在这里插入图片描述

这时可以打开APICloud的开发工具APICloud Studio 2,当然也可以使用APICloud Studio 1或者其它任何带有apicloud-cli的编辑器。这里以APICloud Studio 2为例。

  1. 在左侧项目栏中右键选择“添加项目文件夹”。
    在这里插入图片描述
  2. 选择apicloud-vue(本目录)下的dist
    在这里插入图片描述
  3. 全量同步到手机(同步之前确保你的Loader与APICloud Studio已经连接)
    在这里插入图片描述
  4. 查看最终效果
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述

六、更多优化

至此,基本的开发逻辑已完成。本文仅仅是演示版本,后续还有很多可以拓展的地方,留给各位小伙伴们思考:

  • 区分开发环境和生产环境
  • 开发环境热加载
  • 修改Vant主题
  • 引入滴滴出行的Mand Mobile组件库
  • ESLint的配置
  • husky和lint-stage的配置
  • apicloud-cli的集成

本文中如果有BUG或者不对的地方,欢迎各位小伙伴留言指正!

谢谢。

Logo

前往低代码交流专区

更多推荐