当你敲下weex preview的时候 它在背后都做了什么?

当我们在开发Weex的时候,总是会使用到Weex官方的开发工具weex-toolkit,在我们使用weex-toolkit,享受它带来的便利的时候,还是有必要去了解一下它背后的整个逻辑的,掌握这样的逻辑有助于我们更好的了解如何更好的使用Weex。

我们这篇文章就以weex preview这个最常用的命令为例,来探究一下这背后的代码。

weex preview能做什么

我们以官方的awesome-project为例,当我们在命令行中敲入weex preview src/components/HelloWorld.vue的时候,我们会看到浏览器自动打开了一个页面,这个页面中一个手机壳一样的web页面,让我们可以查看Weex代码编译出来的效果,还有一个二维码,通过playground这样的扫码工具进行扫码,可以在移动端打开这个Weex页面。

从这一个页面我们不难看出一点:同样的一份代码,我们通过一个命令,构建出既适用于web端又适用于移动端的产物,那么这个产物究竟是如何被构建出来的呢?

构建过程

在开始fuck source code之前,我们要先了解一下如何使用node来开发命令行工具,这里我推荐一下阮一峰的Node.js 命令行程序开发教程

看完文章之后,我们来看weex-toolkit是怎么做的。根据惯例,我们看weex-toolkit这个项目bin目录下的js文件:

1
xtoolkit.command('preview', 'npm:weex-previewer').locate(require.resolve('weex-previewer'));

其中和我们今天文章相关的一行就是这个,从代码中我们知道,preview最终是调用了weex-previewer这个npm项目,那我们就再来看weex-previewer的bin目录里的js。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
detect(program.port).then((open) => {
const target = pipe(program.args)
if (target) {
// If permission to track use
let entryCount = 0;
let fileType;
let options;
let optionflags;
const entryType = {
2: 'single',
6: 'folder'
}
if (target.entry) {
entryCount+=2;
fileType = helper.getFileType(path.basename(target.entry))
}
if (target.folder) {
entryCount+=4;
}
optionflags = {
entry: !!program.entry,
port:!!program.port,
verbose:!!program.verbose,
config:!!program.config,
loglevel:!!program.loglevel
}
hook.record('/weex_tool.weex-previewer.sence', { file_type: fileType, entry: entryType[entryCount], options: options});

options = {
config:program.config
}
logger.info('Bundling source...')
preview(target, open, options);
}
})

const pipe = (args) => {
if (!args || !args[0]) {
program.outputHelp();
return false;
}
const target = args[0];
const ext = path.extname(target);
let result = {
folder: '',
entry: ''
}
if(!fs.existsSync(target)){
logger.error(`Not found file ${target}`);
return false;
}
if (!ext) {
result.folder = target;
if (!program.entry) {
logger.error(`Need to config the entry file like: \`${binname} ${target} --entry ${path.join(target, 'index.vue')}\``);
return false;
}
else {
result.entry = program.entry
}
}
else {
result.entry = target || ''
}
return result;
}

其中通过pipe方法去组装了一个参数,并且调用preview方法去处理真正的逻辑。

preview方法的逻辑比较复杂,我们一点一点来看:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
init: function (args, port, options) {
if (!helper.checkEntry(args.entry)) {
return logger.error('Not a ".vue" or ".we" file');
}
this.params = Object.assign({}, defaultParams, args);
this.params.options = options;
this.params.port = port;
this.params.wsport = port + 1;
this.params.source = this.params.folder || this.params.entry;
if (this.params.folder) {
this.file = path.relative(this.params.source, this.params.entry);
}
else {
this.file = this.params.entry;
}
this.fileType = helper.getFileType(this.file);
this.module = this.file.replace(path.extname(this.file), '');
this.fileDir = process.cwd();
return this.fileFlow();
}

首先,根据文件名获取文件的类型:

1
2
3
getFileType: function (filename) {
return /\.vue$/.test(filename) ? 'vue' : 'we';
}

接着,调用fileFlow方法:

1
2
3
4
5
6
7
8
9
fileFlow () {
logger.verbose(`init template diretory to ${this.params.temDir}`);
this.initTemDir();
logger.verbose('building JS file');
this.buildJSFile(() => {
logger.verbose('start server');
this.startServer();
});
}

在fileFlow方法中,先调用了initTemDir方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const WEEX_TMP_DIR = '.weex_tmp';

initTemDir () {
if (!fs.existsSync(this.params.temDir)) {
this.params.temDir = WEEX_TMP_DIR;
fs.mkdirsSync(WEEX_TMP_DIR);
fs.copySync(`${__dirname}/../vue-template/template/`, WEEX_TMP_DIR);
}
// replace old file
fs.copySync(`${__dirname}/../vue-template/template/weex.html`, `${this.params.temDir}/weex.html`);
const vueRegArr = [{
rule: /{{\$script}}/,
scripts: `
<script src="./assets/vue.runtime.js"></script>
<script src="./assets/weex-vue-render/index.js"></script>
`

}];
const weRegArr = [{
rule: /{{\$script}}/,
scripts: `
<script src="./assets/weex-html5/weex.js"></script>
`

}];
let regarr = vueRegArr;
if (this.fileType === 'we') {
regarr = weRegArr;
}
else {
this.params.webSource = path.join(this.params.temDir, 'temp');
if (fs.existsSync(this.params.webSource)) {
fs.removeSync(this.params.webSource);
}
helper.createVueSrc(this.params.source, this.params.webSource);
}
helper.replace(path.join(`${this.params.temDir}/`, 'weex.html'), regarr);
}

可以看到,创建了一个.weex_temp文件夹。各位同学可以在敲下weex preview命令之后去看一下你的工程里,是会多了一个.weex_temp文件夹的,就是这么来的。

接着,调用了helper的createVueSrc和replace方法,在.weex_temp文件夹下创建了一个weex.html文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>weex-vue-demo</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no, email=no">
<!-- <style>body > div { height: 100%; }</style> -->
<style>body::before { content: "1"; height: 1px; overflow: hidden; color: transparent; display: block; }</style>
<script src="./assets/vue.runtime.js"></script>
<script src="./assets/weex-vue-render/index.js"></script>
</head>
<body>
<div id="root"></div>
<script src="./assets/weex-init.js"></script>
</body>
</html>

其中最重要的三行是:

1
2
3
<script src="./assets/vue.runtime.js"></script>
<script src="./assets/weex-vue-render/index.js"></script>
<script src="./assets/weex-init.js"></script>

注入了这三个js文件,它们是做什么用的,我们之后再看。

回到weex-previewer,在创建好了.weex_temp之后,调用了buildJSFile方法,看名字我们就可以猜到,这就是最关键的一步了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
buildJSFile (callback) {
const buildOpt = {
watch: true,
ext: /\.js$/.test(this.params.entry) ? 'js' : this.fileType,
...this.params.options
};
let source = this.params.entry;
const dest = path.join(this.params.temDir, 'dist');
let webDest;
let vueSource = this.params.source;
if (this.params.folder) {
source = this.params.folder;
vueSource = this.params.folder;
buildOpt.entry = this.params.entry;
}
else {
webDest = path.join(this.params.temDir, 'dist', this.params.entry.replace(path.basename(this.params.entry), ''));
}
if (this.fileType === 'vue') {
if (buildOpt.entry) {
buildOpt.entry = this.params.entry;
}
else {
source = this.params.entry;
}
// for weex
this.build(vueSource, dest, buildOpt, () => {
logger.info('weex JS bundle saved at ' + path.resolve(this.params.temDir));
// for web
this.build(this.params.webSource, webDest || dest, {
web: true,
ext: 'js'
}, callback);
}, () => {
// for web
this.build(this.params.webSource, webDest || dest, {
web: true,
ext: 'js'
}, callback);
});
}
else {
this.build(source, dest, buildOpt, callback);
}
},
build (src, dest, opts, buildcallback, watchCallback) {
if (!opts.web && path.extname(src) === '.vue') {
dest += '/[name].weex.js';
}
else if (!opts.web && path.extname(src) !== '.vue') {
opts['filename'] = '[name].weex.js';
}
builder.build(src, dest,
{
...opts,
...this.params.options
},
(err, fileStream) => {
if (!err) {
if (this.wsSuccess) {
if (typeof watchCallback !== 'undefined') {
watchCallback();
}
logger.info(fileStream);
server.sendSocketMessage();
}
else {
buildcallback();
}
}
else {
logger.error(err);
}
});
},
startServer () {
const self = this;
server.run({
dir: this.params.temDir,
module: this.module,
fileType: this.fileType,
port: this.params.port,
wsport: this.params.wsport,
open: this.params.open,
wsSuccessCallback () {
self.wsSuccess = true;
}
});
}

这个方法比较长,但是总结起来就是两点:

(1) 在build方法中调用weex-builder去构建js文件。

(2) startSever方法中起一个http服务。

在weex-builder这个工程中,就是我们喜闻乐见的webpack打包,值得一提的是,其中对weex和web环境进行了区分,weex环境下使用weex-loader打包,而web环境下使用vue-loader打包。打包出来是2个不同的js,一个为html.weex.js,而一个为html.js。

至此,我们知道了整个链路中最关键的一步:分别使用weex和web的webpack配置去打包同一份源码,从而打出两个不同的目标js文件。

最后,我们来看startServer。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
run (args) {
const params = args;
const options = {
root: params.dir,
cache: '-1',
showDir: true,
autoIndex: true
};
this.rootDir = params.dir;
if (!this.checkPort(params.port)) {
return logger.info('HTTP port is illegal and please try another');
}
this.bindProcessEvent();
const servers = httpServer.createServer(options);
servers.listen(params.port, '0.0.0.0', () => {
logger.info((new Date()) + `http is listening on port ${params.port}`);
const IP = this.getLocalIP();
const previewUrl = `http://${IP}:${params.port}/?hot-reload_controller&page=${params.module}.js&loader=xhr&wsport=${params.wsport}&type=${params.fileType}`;
if (params.open) {
opener(previewUrl);
}
logger.info(previewUrl);
});
this.startWebSocket(params.wsport, params.wsSuccessCallback);
return servers;
}

可以看到就是起了一个http服务,根目录是.weex_temp文件夹。而我们去看.weex_temp文件夹,会发现有一个index.html的入口文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Weex Preview</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-touch-fullscreen" content="yes">
<meta name="format-detection" content="telephone=no, email=no">
<link rel="stylesheet" href="./assets/style.css">
<script src="./assets/qrcode.js"></script>
<script src="./assets/vue.js"></script>
</head>
<body>
<h1>Weex Preview</h1>
<div id="app"></div>
<template id="app-template">
<div id="app">
<div class="mock-phone">
<div class="inner">
<iframe id="preview" :src="src"></iframe>
</div>
<div class="camera"></div>
<div class="earpiece"></div>
<div class="home-btn"></div>
</div>
<div id="qrcode">
<h2>QRCode</h2>
<a :href="val" target="_blank"><canvas ref="canvas" width="200" height="200"></canvas></a>
<p class="bundle-url"><a :href="url" target="_blank">查看文件源码</a></p>
</div>
</div>
</template>
<script>
function getUrlParam(key,searchStr) {
var reg = new RegExp('[?|&]' + key + '=([^&]+)');
searchStr = searchStr || location.search;
var match = searchStr.match(reg)
return match && match[1]
}
var module = getUrlParam('page') || 'app.js';
if(getUrlParam('type') == 'vue') {
module = module.replace(/\.js$/,'.weex.js');
}
var protocol = location.protocol + '//'
var hostname = location.hostname;
var wsport = getUrlParam('wsport') || '8082';
var port = location.port ? ':' + location.port : '';
var url = protocol + hostname + port + location.pathname.replace(/\/index\.html$/, '/').replace(/\/$/,'/' + module);

new Vue({
el: '#app',
template: '#app-template',
data: {
val: url + '?hot-reload_controller=1&_wx_tpl=' + url,
url: url,
src: "./weex.html?req=" + Math.floor(Math.random() * 100000) + "&page=" + getUrlParam('page'),
},
mounted: function () {
var qrcodedraw = new QRCodeLib.QRCodeDraw()
qrcodedraw.draw(this.$refs.canvas, this.val.replace('.web',''), function () {})
}
})
//for hot reload
startSocketCheck();

function startSocketCheck() {
if (location.protocol.match(/file/)) {
return;
}
if (location.search.indexOf('hot-reload_controller') === -1) {
return;
}
if (typeof WebSocket === 'undefined') {
console.info('auto refresh need WebSocket support');
return;
}
var host = location.hostname;
var port = wsport;
try {
var client = new WebSocket('ws://' + host + ':' + port + '/', 'echo-protocol');
client.onerror = function () {
console.log('refresh controller websocket connection error');
};
client.onmessage = function (e) {
console.log('Received: \'' + e.data + '\'');
if (e.data === 'refresh') {
location.reload();
}
};
}catch(er) {
console.log(er);
}

};

</script>

</body>
</html>

可以看到,代码中的ifame就对应文章开头说的页面中的手机壳,而二维码组件则对应可以扫的那个二维码。

我们先看iframe,iframe中的src参数为:

1
2
3
4
5
6
7
8
src: "./weex.html?req=" + Math.floor(Math.random() * 100000) + "&page=" + getUrlParam('page')

function getUrlParam(key,searchStr) {
var reg = new RegExp('[?|&]' + key + '=([^&]+)');
searchStr = searchStr || location.search;
var match = searchStr.match(reg)
return match && match[1]
}

可以看到,src就是我们前面提到的weex.html。而weex.html中会引用weex-init.js这个文件:

1
2
3
4
5
6
7
8
9
10
11
12
(function () {
function getUrlParam (key) {
var reg = new RegExp('[?|&]' + key + '=([^&]+)')
var match = location.search.match(reg)
return match && match[1]
};
var page = getUrlParam('page') || 'index.js';
var bundle = document.createElement('script')
// only for web
bundle.src = page
document.body.appendChild(bundle)
})();

这个文件就是在body中插了一个js文件而已。而这个js就是前面我们使用weex-builder打包出来的HelloWorld.js。

而这个js之所以能够被加载出来,就是因为在wee.html中使用了上述提到的weex-vue-render和vue-runtime这两个组件。

讲完了web端,我们回过头来讲一下移动端。移动端非常简单,其实就是二维码关联了HelloWorld.weex.js这个文件,在扫码的时候通过移动端sdk进行加载而已。

总结

通过上面的分析,我们可以知道如果需要将一份weex的源代码构建出在移动端和web端可用的两份资源,我们需要做以下几件事:

  1. 使用weex-loader和vue-loader分别对源码进行打包,从而得出两份js构建产物(其实也可以使用同一个loader的,那就是直接使用weex-vue-loader)。
  2. 在web端中使用,需要结合weex-vue-render和vue-runtime这两个js,用以抹平weex和web端的差异(事实上,在注入了这两个js之后,我们可以在全局中得到了一个已经被挂载了的weex实例,而我们可以通过这个weex实例来进行registerModule,registerComponent等操作)。
  3. 移动端很简单,直接使用weex sdk进行渲染就好。