目录
在上一篇文章中,我建议大家根据APICloud官方使用原生JS
开发的建议,在不影响项目结构和性能的情况下,将Vue.js以script
引用的方式直接作用于页面上,使用最基本的vue.js逻辑来做全局或者局部的页面渲染
。
虽然直接通过script引入的方式也可以正常使用vue.js,但这样也存在许多不便。
例如:
ES6及以上语法
的问题不易维护
组件库
一般来说,大型团队很少有直接使用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
创建出包括vuex
、vue-router
在内的单页面项目,通过build之后将dist下的单页面内容绑定到单个html上,然后将这个单页html文件和相关js文件上传到APICloud中。这种做法虽然能够正常运行,但是却背离了APICloud平台最初的Hybrid App
的初衷,从而直接变成了一个纯粹的h5项目。
在移动端设备下,使用vue-router
配合h5模拟出一个页面切换的效果,其效率及流畅度或用户体验是远远不及APICloud中openWin
、openFrame
这类调用原生接口实现窗口管理的效果的。
通常的vue-cli项目是基于webpack
构建的,已经有了默认配置。其默认配置中入口只有一个,反观APICloud项目的基本结构,每一个html页面都需要引入相关文件且页面之间的依赖引用不会相互冲突,因此每个窗口页面都可以理解为是一个独立的入口。
在这个基本思路下,我们可以提出一个假设:将vue项目打包成与APICloud项目一模一样的结构。
结合以上假设及APICloud项目开发经验,我们可以得出以下几个需求点:
根据以上几个需求点,我们可以得出大致流程:
其中最重要的一步就是配置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"
}
}
创建基本项目目录并创建示例页面:
全局样式文件取决于项目需要,这里可要可不要。在这里默认为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 }
将页面组件化导出。而现在则需要将每个页面的style、html、script分别提取成对应名称的文件,提取后的样式通过link引入模板页,脚本通过script引入模板页,而模板页如何获取到脚本内容呢,这里可以通过将vue脚本内容绑定到window对象
进行中转取值。
这里无需给每个页面绑定到window的不同attribute上,因为APICloud的页面管理系统中,每个窗口的window对象都是独立的,因此可以统一命名成相同的名称,如$page。
src/templates/page.ejs:
<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.base.js、webpack.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 安装说明的推荐,我们需要修改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
为例。
至此,基本的开发逻辑已完成。本文仅仅是演示版本,后续还有很多可以拓展的地方,留给各位小伙伴们思考:
本文中如果有BUG或者不对的地方,欢迎各位小伙伴留言指正!
谢谢。