话很少说先上图,简要说明一下干了些什么事。图可能太模糊,能够点svg看看 javascript
最近公司开展了小程序的业务,派我去负责这一块的业务,其中须要处理的一个问题是接入咱们web开发的传统架构--模块化开发。 咱们来详细说一下模块化开发具体是怎么样的。 咱们的git工做流采用的是git flow。一个项目会拆分红几个模块,而后一人负责一个模块(对应git flow的一个feature)独立开发。模块开发并与后端联通后再合并至develop进行集成测试,后续通过一系列测试再发布版本。 目录结构大致如图所示,一个模块包含了他本身的pages / components / assets / model / mixins / apis / routes / scss等等。css
这种开发模式的好处不言而喻,每一个人均可以并行开发,大大提高开发速度。此次就是要移植这种开发模式到小程序中。html
背景说完了,那么来明确一下咱们的目标。 我采用的是wepy框架,类vue语法的开发,开发体验很是棒。在vue中,一个组件就是单文件,包含了js、html、css。wepy采用vue的语法,但由与vue稍稍有点区别,wepy的组件分为三种--wepy.app类,wepy.page类,wepy.component类。 对应到咱们的目录结构中,每一个模块实际上就是一系列的page组件。要组合这一系列的模块,那么很简单,咱们要作的就是把这一系列page的路由扫描成一个路由表,而后插入到小程序的入口--app.json中。对应wepy框架那便是app.wpy中的pages字段。前端
第一步!先获得全部pages的路由并综合成一个路由表! 个人方案是,在每一个模块中新建一份routes文件,至关于注册每一个须要插入到入口的page的路由,不须要接入业务的page就不用注册啦。是否是很熟悉呢,对的,就是参考vue-router的注册语法。vue
//routes.js
module.exports = [
{
name: 'home-detail',//TODO: name先占位,后续再尝试经过读name跳转某页
page: 'detail',//须要接入入口的page的文件名。例如这里是index.wpy。相对于src/的路径就是`modules/${moduleName}/pages/index`。
},
{
name: 'home-index',
page: 'index',
meta: {
weight: 100//这里加了一个小功能,由于小程序指定pages数组的第一项为首页,后续我会经过这个权重字段来给pages路由排序。权重越高位置越前。
}
}
]
复制代码
而扫描各个模块并合并路由表的脚本很是简单,读写文件就ok了。java
const fs = require('fs')
const path = require('path')
const routeDest = path.join(__dirname, '../src/config/routes.js')
const modulesPath = path.join(__dirname, '../src/modules')
let routes = []
fs.readdirSync(modulesPath).forEach(module => {
if(module.indexOf('.DS_Store') > -1) return
const route = require(`${modulesPath}/${module}/route`)
route.forEach(item => {
item.page = `modules/${module}/pages/${item.page.match(/\/?(.*)/)[1]}`
})
routes = routes.concat(route)
})
fs.writeFileSync(routeDest,`module.exports = ${JSON.stringify(routes)}`, e => {
console.log(e)
})
复制代码
路由排序策略node
const strategies = {
sortByWeight(routes) {
routes.sort((a, b) => {
a.meta = a.meta || {}
b.meta = b.meta || {}
const weightA = a.meta.weight || 0
const weightB = b.meta.weight || 0
return weightB - weightA
})
return routes
}
}
复制代码
最后得出路由表webpack
const Strategies = require('../src/lib/routes-model')
const routes = Strategies.sortByWeight(require('../src/config/routes'))
const pages = routes.map(item => item.page)
console.log(pages)//['modules/home/pages/index', 'modules/home/pages/detail']
复制代码
So far so good...问题来了,如何替换入口文件中的路由数组。我以下作了几步尝试。git
我第一感受就是,这不很简单吗?在wepy编译以前,先跑脚本得出路由表,再import这份路由表就得了。程序员
import routes from './routes'
export default class extends wepy.app {
config = {
pages: routes,//['modules/home/pages/index']
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '你们好我是渣渣辉',
navigationBarTextStyle: 'black'
}
}
//...
}
复制代码
然而这样小程序确定会炸啦,pages字段的值必须是静态的,在小程序运行以前就配置好,动态引入是不行的!不信的话诸君能够试试。那么就是说,划重点---咱们必须在wepy编译以前再预编译一次---事先替换掉pages字段的值!
既然要事先替换,那就是要精准定位pages字段的值,而后再替换掉。难点在于若是精准定位pages字段的值呢? 最捞然而最快的方法:正则匹配。 事先定好编码规范,在pages字段的值的先后添加/* __ROUTES__ */
的注释
脚本以下:
const fs = require('fs')
const path = require('path')
import routes from './routes'
function replace(source, arr) {
const matchResult = source.match(/\/\* __ROUTE__ \*\/([\s\S]*)\/\* __ROUTE__ \*\//)
if(!matchResult) {
throw new Error('必须包含/* __ROUTE__ */标记注释')
}
const str = arr.reduce((pre, next, index, curArr) => {
return pre += `'${curArr[index]}', `
}, '')
return source.replace(matchResult[1], str)
}
const entryFile = path.join(__dirname, '../src/app.wpy')
let entry = fs.readFileSync(entryFile, {encoding: 'UTF-8'})
entry = replace(entry, routes)
fs.writeFileSync(entryFile, entry)
复制代码
app.wpy的变化以下:
//before
export default class extends wepy.app {
config = {
pages: [
/* __ROUTE__ */
/* __ROUTE__ */
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '你们好我是渣渣辉',
navigationBarTextStyle: 'black'
}
}
//...
}
//after
export default class extends wepy.app {
config = {
pages: [
/* __ROUTE__ */'modules/home/pages/index', /* __ROUTE__ */
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '你们好我是渣渣辉',
navigationBarTextStyle: 'black'
}
}
//...
}
复制代码
行吧,也总算跑通了。由于项目很赶,因此先用这个方案开发了一个半星期。开发完以后总以为这种方案太难受,因而密谋着换另外一种各精准的自动的方案。。。
想必你们确定很熟悉这种模式
let host = 'http://www.tanwanlanyue.com/'
if(process.env.NODE_ENV === 'production'){
host = 'http://www.zhazhahui.com/'
}
复制代码
经过这种只在编译过程当中存在的全局常量,咱们能够作不少值的匹配。 由于wepy已经预编译了一层,在框架内的业务代码是读取不了process.env.NODE_ENV的值。我就想着要不作一个相似于webpack的DefinePlugin的babel插件吧。具体的思路是babel编译过程当中访问ast时匹配须要替换的标识符或者表达式,而后替换掉相应的值。例如: In
export default class extends wepy.app {
config = {
pages: __ROUTE__,
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '你们好我是渣渣辉',
navigationBarTextStyle: 'black'
}
}
//...
}
复制代码
Out
export default class extends wepy.app {
config = {
pages: [
'modules/home/pages/index',
],
window: {
backgroundTextStyle: 'light',
navigationBarBackgroundColor: '#fff',
navigationBarTitleText: '你们好我是渣渣辉',
navigationBarTextStyle: 'black'
}
}
//...
}
复制代码
在这里先要给你们推荐几份学习资料: 首先是babel官网推荐的这份迷你编译器的代码,读完以后基本能理解编译器作的三件事:解析,转换,生成的过程了。 其次是编写Babel插件入门手册。基本涵盖了编写插件的方方面面,不过因为babel几个工具文档的缺失,在写插件的时候须要去翻查代码中的注释阅读api用法。 而后是大杀器AST转换器--astexplorer.net。咱们来看一下,babel的解析器--babylon的文档,涵盖的节点类型这么多,脑绘一张AST树不现实。我在编写脚本的时候会先把代码放在转换器内生成AST树,再一步一步走。
编写babel插件以前先要理解抽象语法树这个概念。编译器作的事能够总结为:解析,转换,生成。具体的概念解释去看入门手册可能会更好。这里讲讲我本身的一些理解。
解析包括词法分析与语法分析。 解析过程吧。其实按个人理解(不知道这样合适不合适= =)抽象语法树跟DOM树其实很相似。词法分析有点像是把html解析成一个一个的dom节点的过程,语法分析则有点像是将dom节点描述成dom树。
转换过程是编译器最复杂逻辑最集中的地方。首先要理解“树形遍历”与“访问者模式”两个概念。
“树形遍历”如手册中所举例子: 假设有这么一段代码:
function square(n) {
return n * n;
}
复制代码
那么有以下的树形结构:
- FunctionDeclaration
- Identifier (id)
- Identifier (params[0])
- BlockStatement (body)
- ReturnStatement (body)
- BinaryExpression (argument)
- Identifier (left)
- Identifier (right)
复制代码
FunctionDeclaration
Identifier (id)
Identifier (id)
Identifier (params[0])
Identifier (params[0])
BlockStatement (body)
ReturnStatement (body)
BinaryExpression (argument)
Identifier (left)
Identifier (left)
Identifier (right)
Identifier (right)
BinaryExpression (argument)
ReturnStatement (body)
BlockStatement (body)
“访问者模式”则能够理解为,进入一个节点时被调用的方法。例若有以下的访问者:
const idVisitor = {
Identifier() {//在进行树形遍历的过程当中,节点为标识符时,访问者就会被调用
console.log("visit an Identifier")
}
}
复制代码
结合树形遍从来看,就是说每一个访问者有进入、退出两次机会来访问一个节点。 而咱们这个替换常量的插件的关键之处就是在于,访问节点时,经过识别节点为咱们的目标,而后替换他的值!
话很少说,直接上代码。这里要用到的一个工具是babel-types
,用来检查节点。
难度其实并不大,主要工做在于熟悉如何匹配目标节点。如匹配memberExpression时使用matchesPattern方法,匹配标识符则直接检查节点的name等等套路。最终成品及用法能够见个人github
const memberExpressionMatcher = (path, key) => path.matchesPattern(key)//复杂表达式的匹配条件
const identifierMatcher = (path, key) => path.node.name === key//标识符的匹配条件
const replacer = (path, value, valueToNode) => {//替换操做的工具函数
path.replaceWith(valueToNode(value))
if(path.parentPath.isBinaryExpression()){//转换父节点的二元表达式,如:var isProp = __ENV__ === 'production' ===> var isProp = true
const result = path.parentPath.evaluate()
if(result.confident){
path.parentPath.replaceWith(valueToNode(result.value))
}
}
}
export default function ({ types: t }){//这里须要用上babel-types这个工具
return {
visitor: {
MemberExpression(path, { opts: params }){//匹配复杂表达式
Object.keys(params).forEach(key => {//遍历Options
if(memberExpressionMatcher(path, key)){
replacer(path, params[key], t.valueToNode)
}
})
},
Identifier(path, { opts: params }){//匹配标识符
Object.keys(params).forEach(key => {//遍历Options
if(identifierMatcher(path, key)){
replacer(path, params[key], t.valueToNode)
}
})
},
}
}
}
复制代码
固然啦,这块插件不能够写在wepy.config.js中配置。由于必须在wepy编译以前执行咱们的编译脚本,替换pages字段。因此的方案是在跑wepy build --watch
以前跑咱们的编译脚本,具体操做是引入babel-core
来转换代码
const babel = require('babel-core')
//...省略获取app.wpy过程,待会会谈到。
//...省略编写visitor过程,语法跟编写插件略有一点点不一样。
const result = babel.transform(code, {
parserOpts: {//babel的解析器,babylon的配置。记得加入classProperties,不然会没法解析app.wpy的类语法
sourceType: 'module',
plugins: ['classProperties']
},
plugins: [
[{
visitor: myVistor//使用咱们写的访问者
}, {
__ROUTES__: pages//替换成咱们的pages数组
}],
],
})
复制代码
固然最终咱们是转换成功啦,这个插件也用上了生产环境。可是后来没有采用这方案替换pages字段。暂时只替换了__ENV__: process.env.NODE_ENV
与__VERSION__: version
两个常量。 为何呢? 由于每次编译以后标识符__ROUTES__
都会被转换成咱们的路由表,那么下次我想替换的时候难道要手动删掉而后再加上__ROUTES__
吗?我固然不会干跟咱们自动化工程化的思想八字不合的事情啦。 不过写完这个插件以后收获仍是挺大的,基本了解该如何经过编译器寻找并替换咱们的目标节点了。
xmldom
这个库来解析,获取script标签内的代码。wepy.app
的类,再找到config
字段,最后匹配key为pages
的对象的值。最后替换目标节点最终脚本:
/** * @author zhazheng * @description 在wepy编译前预编译。获取app.wpy内的pages字段,并替换成已生成的路由表。 */
const babel = require('babel-core')
const t = require('babel-types')
//1.引入路由
const Strategies = require('../src/lib/routes-model')
const routes = Strategies.sortByWeight(require('../src/config/routes'))
const pages = routes.map(item => item.page)
//2.解析script标签内的js,获取code
const xmldom = require('xmldom')
const fs = require('fs')
const path = require('path')
const appFile = path.join(__dirname, '../src/app.wpy')
const fileContent = fs.readFileSync(appFile, { encoding: 'UTF-8' })
let xml = new xmldom.DOMParser().parseFromString(fileContent)
function getCodeFromScript(xml){
let code = ''
Array.prototype.slice.call(xml.childNodes || []).forEach(child => {
if(child.nodeName === 'script'){
Array.prototype.slice.call(child.childNodes || []).forEach(c => {
code += c.toString()
})
}
})
return code
}
const code = getCodeFromScript(xml)
// 3.在遍历ast树的过程当中,嵌套三层visitor去寻找节点
//3.1.找class,父类为wepy.app
const appClassVisitor = {
Class: {
enter(path, state) {
const classDeclaration = path.get('superClass')
if(classDeclaration.matchesPattern('wepy.app')){
path.traverse(configVisitor, state)
}
}
}
}
//3.2.找config
const configVisitor = {
ObjectExpression: {
enter(path, state){
const expr = path.parentPath.node
if(expr.key && expr.key.name === 'config'){
path.traverse(pagesVisitor, state)
}
}
}
}
//3.3.找pages,并替换
const pagesVisitor = {
ObjectProperty: {
enter(path, { opts }){
const isPages = path.node.key.name === 'pages'
if(isPages){
path.node.value = t.valueToNode(opts.value)
}
}
}
}
// 4.转换并生成code
const result = babel.transform(code, {
parserOpts: {
sourceType: 'module',
plugins: ['classProperties']
},
plugins: [
[{
visitor: appClassVisitor
}, {
value: pages
}],
],
})
// 5.替换源代码
fs.writeFileSync(appFile, fileContent.replace(code, result.code))
复制代码
只须要在执行wepy build --watch
以前先执行这份脚本,就可自动替换路由表,自动化操做。监听文件变更,增长模块时自动从新跑脚本,更新路由表,开发体验一流~
把代码往更自动化更工程化的方向写,这样的过程收获仍是挺大的。可是确实这份脚本仍有不足之处,起码匹配节点这部分的代码是不大严谨的。 另外插播一份广告 我司风变科技正招聘前端开发:
邮箱:nicolas_refn@foxmail.com