本文由cy,wxy 共同完成,排名不分先后。
0 前言
在网易内部,很多部门都是以NEJ+Regular作为基础库来进行前端开发,因此,各个部门也基于此积累了很多库,组件。这也产生了一个问题,一个新的项目如果启动,想去使用以前的积累,就必须和以前采用相同的技术方案以及选型,那么,有没有一种方法,在新项目(业务)启动时,既能兼容以往部门所沉淀的技术组件,又能够使用新技术,新方案给开发带来全新的感受,我们在【卡搭校园】这个产品做了以下的尝试。
1 NEJ篇
在网易内部,大家一定对NEJ不陌生,早期乃至现在很多业务都基于NEJ框架进行开发,NEJ也包揽了模块依赖、umi调度系统、platform适配、构建打包等多个功能,可以说涵盖了从开发到构建上线的所有生命周期,足见之强大。可也正因为此,NEJ也显示的很重,无法像webpack,postcss,babel等通过插件/loader,来进行二次开发,有时候,一些新的想法和点子只能通过小聪明去解决。基于这样的考虑,我们首先做了一件事,解耦NEJ,即NEJ代码不需要通过自有的打包构建工具,而能被任意第三方框架/仓库使用。
1.1 模块依赖
众所周知,NEJ有属于自己的模块依赖系统,即NEJ.define(),一个标准的NEJ模块系统会有这样的代码
NEJ.define( [
'pro/{mode}/base'
'./component.js',
'text!./web/component.html',
'css!./web/component.css'
],function(Base,Button,html,css,p,o,f,r){
// ...具体逻辑
})
显然,NEJ的模块依赖系统属于AMD类型,但是其语法与标准的AMD模块相比,又更具灵活性:
- 文件依赖url支持参数,可通过define.js配置:pro/{mode}/base
- 支持text,css,regular等拓展关键字来引入不同类型的文件:text!./web/component.html
- 模块中会自动注入4个参数:p,输出结果集空间;o,一个空对象;f,一个return false的函数;r,一个空数组;
1.2 模块转化:babel插件
如果能够找出NEJ的私有模块依赖系统与标准的模块系统之间的不同之处,将NEJ的私有模块依赖系统转化为标准的AMD/CMD/CommonJS/ES6 module系统,是不是也就意味着NEJ模块可以被其它类型的模块所用?
基于此,我们决定采取编写Babel插件的方式来进行NEJ module -> CommonJS module的转化。
Babel 是 JavaScript 编译器,更确切地说是源码到源码的编译器,通常也叫做“转换编译器(transpiler)”。 意思是说你为 Babel 提供一些 JavaScript 代码,Babel 更改这些代码,然后返回给你新生成的代码。
1.2.1 使用babel转化NEJ需要解决的问题
除了以上三点,babel转化NEJ的过程中,我们还需要解决如下几个问题:
- NEJ return && 输出结果集空间的处理
- 依赖系统中循环依赖的处理
- NEJ依赖中,默认this = window
- NEJ很多文件在严格模式下会报错,因为存在很多非严格写法,(如 xxx.bind(undefine),非严格模式情况下, this指向window;而严格模式下this指向undefined)
在不断的分析NEJ源码以及尝试下,我们小组完成了babel插件babel-plugin-transform-nej-module
来做这件事情。
1.2.2 插件效果示例
对一个常见的NEJ模块文件:
// NEJ模块
NEJ.define( [
'../component.js',
'text!./component.html',
'text!./component.css'
],function(
Component,
html,
css,
pro,
o, f, r
) {
return uxModal;
});
babel-plugin-transform-nej-module
会将其转化为:
// CommonJS模块
(function nejModule() {
var Component = require('../component');
var html = require('./component.html');
var css = require('./component.css');
var css = "";
var pro = exports;
var o = {};
var f = function () {};
var r = [];
module.exports = uxModal;
return;
}).call(window);
1.2.3 插件运行剖析
插件的运行流程图及每一步的解释如下:
image-
从文件的根开始:为什么要从根开始?在babel插件的不同的visitor中,难免会需要共享变量。这些共享变量的初始化,应该在每个文件第一次进入时进行。因此选择从根开始处理文件,以便在文件开始运行前做一些初始化处理。(Babel插件处理文件的顺序是并行的还是串行的,这一点尚有待验证。如果是串行的,在pre阶段进行初始化更好。)
-
取第一层节点:JS文件被Babylon parse成一棵AST树,取该树的第一层节点开始后续处理。
为什么取第一层?
- 在以NEJ为基础的前端工程中,所有的业务代码都包含在
NEJ.define
/define
语句中,形成模块。
如果NEJ模块被包含在其他代码块中,那它不一定能被执行到,则该模块是无效的。
即使一定能执行到,在NEJ打包时,也不会将模块外部的代码打包。 - 如果从任意一条语句开始,可能会遇到其他文件中的define函数被识别为nej模块情况。
因此,我们只考虑一个文件就是一个nej模块的情况
-
判断NEJ模块:
nej.define()
或者define()
函数被识别为NEJ模块 -
获取依赖列表和回调函数:处理了无依赖列表、依赖列表为空、回调函数为变量的情况。
-
进一步寻找回调函数(当回调函数为变量的时候):访问模块内代码的所有赋值语句,找到对回调函数变量赋值的语句。
-
将回调函数命名为nejModule:这一步很重要。用来定位回调函数的return和输出结果集空间的变动,并进行跟踪修改。
-
处理return:回调函数对应的
return xxx
为module.exports = xxx; return;
-
处理依赖:
- 初始化注入参数;
- 标准化依赖路径;
- 初始化css文件为空字符串,以兼容NEJ对css的处理,尽管这些处理在不使用define函数的时候是无效的。
-
处理输出结果集空间(设为pro):将所有使用到
pro
的代码替换为exports
,(不直接module.exports = pro,因为需要处理循环依赖) -
保证模块内this指向window:使用自执行函数将全部语句包装起来。
1.2.4 方案不足之处
- 尚存在两种罕见的异常未做处理:
-
text!
方式引入的css文件为空字符串,实际应该是css文件的内容; - 输出结果集空间被直接修改为其它对象;
- NEJ对于不同平台的适配处理:
-
{platform}/element.js
被转化为./platform/element.js
,实际应当同时引入./platform/element.patch.js
如果你发现了任何其它异常情况,请一定要提出issue或直接联系我们,我们将会在最快时间内解决
1.3 与webpack的结合
NEJ模块转化为标准Common JS模块后,还需要再两项额外配置,来保证与webpack的结合使用:
- 将NEJ的路径参数配置为weppack别名,以下是已知的NEJ路径参数配置:
module.exports = {
...
resolve: {
alias: {
'base': resolve('lib/nej/src/base'),
'lib': resolve('lib/nej/src'),
'ui': resolve('lib/nej/src/ui'),
'util': resolve('lib/nej/src/util')
}
}
...
}
- 在插件中配置非前缀的路径参数、以及严格模式的去除:
"plugins": [
...
"transform-remove-strict-mode",
[
"transform-nej-module",
{
"mode": "web"
}
],
///
],
2 Regular篇
说完如何处理NEJ,现在我们来分析一下,如何处理部门所沉淀的regular组件库?
我们所在的部门从16年起,开始搭建了一套用regular编写的组件库,大大小小封装了上百个模块及组件,为各条产品线提供支持。
众所周知,vue template和regular template是不同DSL语法的模板技术,两个框架拥有各自的语法,
在框架层面,如何能最小程度的兼容regular组件成了我们需要优先考虑的问题。
2.1 原理探究
在regularjs中,组件被拆分为了 模板template + 数据data + 业务逻辑(实例函数)的组合。也就是说,只要满足上述三个条件,就可以实例出一个regular组件。
regular组件有两种使用方式,一种是直接实例化,一种是标签式。
1.对于直接使用new方法实例出来的组件,将所需要数据传入即可,注意要及时销毁。
2.对于标签式组件,就必须要有一个地方来承载regular模版,于是想到可以通过注册一个通用的vue组件,拿到regularjs模版,传入数据与逻辑,在该组件中进行实例化,从而达到承载regular组件的效果。
2.2 开始设计
我们的目的就是设计并实现这样一个RegularComponent(简称RC)组件。
首先遇到的问题是如何获取标签内的模版?
我们可以利用vue插槽
业务组件
// 业务组件模版
<rc ref="rc" :revent="REvent" :rdata="RData" :rfilter="RFilter">
<ux-button value={name} on-click={this.clickBtn($event)}></ux-button>
</rc>
RC组件
// RC组件模版
<div ref="rc">
<slot/>
</div>
这样就可以在RC组件中,拿到
-
this.$slots
模版 -
this.rdata
数据 -
this.revent
业务逻辑 -
this.rfilter
过滤器
这里需要注意,this.$slots
直接获取到的是vue规范的AST,下面称为VNode,我们可以封装一个方法将VNode转为模版字符串,再去执行实例化regular的行为。
let attrStringify = obj => {
let ret = '';
for (let i in obj) {
if (obj[i]) {
ret += ' ' + i + '="' + obj[i] + '"';
} else {
ret += ' ' + i;
}
}
return ret;
};
// 拼装模版
let formatTpl = arr => {
let tpl = '';
arr.forEach(item => {
if (item.text) {
tpl += item.text;
} else if (item.tag) {
tpl += '<' + item.tag;
// 组装attr
if (item.data) {
tpl += attrStringify(item.data.attrs) + '>';
} else {
tpl += '>';
}
// 组装子节点
if (item.children) {
tpl += formatTpl(item.children);
}
// 闭合组件标签
tpl += '</' + item.tag + '>';
} else if (item.children) {
tpl += formatTpl(item.children);
}
});
return tpl;
};
拿到这些数据,就可以生成reuglar组件了。
到了这一步,还没有完全实现我们想要的效果,因为vue插槽会将内容分发至子组件,查看源码可以看到模版代码直接展示在页面上;而且模版中的标签式组件是在regular中注册的,直接使用会报错,这是不希望看到的。
[Vue warn]: Unknown custom element: <ux-button> - did you register the component correctly? For recursive components, make sure to provide the "name" option.
不过修复这个问题并不困难,只需隐藏这个插槽即可。
<div ref="rc">
<slot v-if="false" />
</div>
现在组件实例化好了,假设需要异步获取数据,同步至regular组件,要如何更新呢?
在regular中,我们一般在接口请求回调中,将返回的数据赋值到this.data上,然后调用regular的update方法来触发一轮脏检查来同步数据。
在使用RC时,当vue组件中RData发生变化时,会自动同步至RC组件,但regular组件实例化完成之后,不会继续更新数据。我们只要在RC组件内部监听这个对象,再去更新regular组件内的数据即可,这个流程可以用下图表示:
image.png2.3 注意事项
1.目前已知regular模版中双大括号会被编译,导致返回错误的AST,可以在最外层加上v-pre
,若有使用r-class、r-style就必须加上,其他场景最好也加上
示例
<rc ref="rc" :revent="REvent" :rdata="RData" :rfilter="RFilter">
<div v-pre>
<ux-button value={name} on-click={this.clickBtn($event)}></ux-button2>
</div>
</rc>
2.需要注意函数中this的指向,vue事件指向vue,regular事件指向regular
3.在regular模版中,若在逗号后面包含空格,如 on-click={this.xxx(item, $event)}
,会导致AST格式出错,解决方案是把空格去掉,或者大括号外加上引号
2.4 小结
为了在vue中支持regular,我们可以注册一个RC组件来承载,它是一个vue组件,通过vue slot(插槽)获取模版,绑定data、event、filter,动态生成regular组件插入到视图层,从而支持了在vue组件中直接编写regular代码的功能。
2.5 该方案的不足
因为我们的项目是采取weex控制组件的各个state,即单向数据流来控制组件中的数据,但是在目前已实现的组件池基本已被regular双向绑定给限制了,虽然也可以在watch中触发commit,dispatch,可这也无疑会将逻辑打乱,因此,针对这部分代码,我们大多没有采用weex管理状态,而交由组件自身去维护,有时候享受不了单向数据流带来的便利性。
3 构建部署篇
我们可以看到,我们通过Babel将完成了对NEJ模块依赖的解耦,通过Vue组件代理,完成了regular组件上的兼容,最后就是去解决如何上线部署了
3.1构建
3.1.1问题
1.【卡搭校园】这个项目,后端还是按照以往的逻辑,将一些后端数据直接写到ftl模板之中。也就是说,最后上线,我们仍然依赖freemarker去解析数据。但是,我们在使用webpack构建开发环境之时,其本质是不支持渲染java模板的。因此,在构建环境,我们要区分开发状态以及上线打包状态,提供不同的代码用来兼容不同平台(node or html/ java)
- 静态资源路径,NEJ-toolkit已经帮忙实现了类似静态资源地址前缀添加,切换到webpack后,不交给webpack打包的js,css,img等资源文件默认是不会自动加前缀的,这块也要想办法进行兼容。
3.1.2 问题解决
先来看看第一个问题,在webpack-server中,默认是通过HtmlWebpackPlugin插件生成自动生成html,同时,该插件可以兼容ejs语法对各种环境进行定制。那么,我们也可以通过他自动生成ftl模板。webpack配置如下
let filename = filePath.substring(filePath.lastIndexOf('\/') + 1, filePath.lastIndexOf('.'));
let conf = {
// 模板来源
template: '!!ejs-compiled-loader!' + filePath,
// 文件名称
filename: filename + '.html',
// 页面模板需要加对应的js脚本,如果不加这行则每个页面都会引入所有的js脚本
chunks: ['manifest', 'vendor', filename],
inject: true
};
// 生产环境转为ftl
if (process.env.NODE_ENV === 'production') {
conf = merge(conf, {
filename: filename + '.ftl',
env: 'production',
minify: {
removeComments: true,
collapseWhitespace: true,
removeAttributeQuotes: true
},
chunksSortMode: 'dependency'
});
}
对应的html模板如下所示
<% if(htmlWebpackPlugin.options.env === 'production'){ %>
<!--生产环境-->
<script>
<#if webUser?exists>
window.webUser = {
<#if webUser.id?exists>id: "${webUser.id?html}",</#if>
end_key: "end_value"
};
</#if>
</script>
<%} else{%>
<!--开发环境-->
<script>
window.webUser = {
"id": 123456
};
</script>
<%} %>
因为在webpack插件中,我们指定了template通过ejs-loader来渲染,因此我们通过参数传递到html中,通过不同的环境模式,来切换开发环境以及部署到服务器与后端渲染的ftl模板,后期如果有新的后端数据需要渲染到ftl上,只需要维护自己的template模板即可。这样处理也带来了另外一个好处,我们在html模板会使用类似如下的语法
<body>
<div id="app"></div>
<% include src/pages/common/footer.html %>
</body>
插件最后生成ftl时会将所有片段组装到一起,避免了ftl语法 <#include> 部署后路径,资源以及模板被缓存等问题
第二个问题即静态资源服务器替换问题,正常情况下,webpack打包的js||css等都可以通过配置output添加对应的静态资源路径,但是如果出现在HtmlWebpackPlugin模板中的静态资源默认是不会添加,我们不可能在开发或者部署的时候手动添加对应的前缀来满足业务上的需求,因为没有找到合适的工具,因此,在自动切换层,我们手动编写了webpack插件来进行解决。
解决思路如下
通过监听 html-webpack-plugin-before-html-processing 对输出的html进行处理,如果发现他没有带静态资源路径名,则给其增加前缀,示例代码如下
function AddStaticServer (options) {
let option = options || {
serverPath: '//kc.stu.126.net'
};
this.serverPath = option.serverPath;
}
AddStaticServer.prototype.apply = function (compiler) {
compiler.plugin('compilation', (compilation) => {
compilation.plugin(
'html-webpack-plugin-before-html-processing',
(data, cb) => {
data.html = generatehtml(data,html)
cb(null, data);
}
);
});
};
webpack对应配置
plugins:[
.....,
.....,
new AddStaticServerPlugin({
serverPath: config.build.assetsPublicPath
})])
]
3.1.3 该方案的不足
在前文解决方案中提到,我们为了区分线上ftl文件以及前端html文件,需要针对性的编写模板,并且通过配置,当遇到通用ftl数据过多的情况下,需要做很多的额外配置以及编写,项目到后期的话也可能会越来越大,因此,在我们的项目中,除了约定必须的model数据外,其他都通过异步接口进行获取,这样也是为了减少后期可能SSR的成本
3.2开发数据mock
原有项目中,我们可以通过网易内部的NEI平台来进行接口mock甚至是ftl的渲染,而在新的工程中,因为开发环境的切换,不在使用ftl作为承载页面进行,因此,需要寻找其他方式进行Mock,
好在webpack-dev-server能够提供这样的能力我们可以配置proxy来对接口进行代理,示例代码如下所示
devServer: {
proxy: {
'/api': 'http://localhost:8002/',
'/j':'http://localhost:8002/',
}
}
nei默认会在8002下启动mock服务器,因此,在开发环境中,我们可以像以往一样,使用nei平台对接口和数据模型进行管理,最后我们只要将webpack-dev-server的接口代理到nei下即可。同样,我们也可以在开发环境中随时更换成线上服务器(需要解决csrf以及cookie的问题),用本地访问真实的数据也成为了可能。
总结
目前,该套方案已经成功运行上线了一段时间,也得到了组内同学的认可,不过,依旧还是有许多地方可以优化,比如
- ftl是否可以去掉,因为目前该方案要针对本地开发环境与线上部署环境生成不同的代码,会有一定的开发开销,如果去掉类似登录状态该如何判断。
- webpack所构建的sourcemap断点会出现一到两行偏差,没有纯源代码调试那么方便
- 目前babel插件处理了 css,text,regular等NEJ插件,如果未来NEJ又有新的关键字插件,需要一起更新,防止转化失败。
- VUE代理的Regular组件,需要不断的监听事件,才可以兼容以前的代码并完成单向数据流操作。
如果大家对这方案有兴趣,并且有更好的建议,欢迎随时联系我哦~
网友评论