0%

背景

本人在公司内主要负责一些前端公共组件的设计开发和维护,免不了涉及将javascript代码打包为模块,并将其发布到npm registry的工作。

在这当中,其实会遇到不少的细节问题,值得总结分享一下,其中一个最近碰到的比较共性的问题,就是对于js库的异步依赖如何处理的问题

场景是这样的:

假设你负责维护的,是一个叫 foo 的公共组件。

随着时间推移,你维护的这个公共组件foo功能越来越丰富,能够满足越来越多的业务场景,越来越多的应用依赖了你维护的这个组件。

但慢慢的,使用你的组件的开发团队对你的组件提出了这些意见:

我们的应用中90%的用户并没有用到你组件的a功能,能否默认不加载?否则对我们应用的初始性能会造成影响。

这种情况,对于维护组件的你应该如何实现呢?下面我们来一起看看。

场景和期望

假设你维护的组件叫 async-dep-module-demo ,当中提供了一个复杂的计算功能,叫 asyncAdd

为了完成此功能,需要引入一个体积比较大的代码。为了性能,我们使用es6的异步import语法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 引入一些同步依赖
import {
syncDep
} from './sync-dep'

function asyncAdd(x, y) {
// 调用同步依赖
syncDep();
// 复杂的逻辑,代码体积较大,通过异步import引入
return import('./async-dep').then(({
asyncDep
}) => {
asyncDep();
return x + y;
})

}

export {
asyncAdd
}

使用你的组件库的应用,会用类似如下的代码来使用:

1
2
3
4
5
6
7
8
import {
asyncAdd
} from 'async-dep-module-demo'

asyncAdd(2, 3).then(res => {
console.log("invoke asyncAdd() ", res)

})

现在使用你的组件库的应用,对你的组件库的要求是,如果应用还没有调用aysncAdd方法,则不要加载那部分代码量大的复杂逻辑( async-dep )。

实现

使用webpack打包你的组件库?

说起前端的打包工具(bundler),大家最熟悉的可能就是 webpack 。那么webpack能达到我们的期望吗?

webpack支持打包js库,输出的模块规范有好几种,可以参考这里:
https://webpack.js.org/configuration/output/#outputlibrarytarget

常用的可能有 umdamdcommonjs 等。

假设我们采用umd格式输出,对应的webpack配置文件大概长这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// webpack.config.js
const path = require('path');

module.exports = {
entry: './src/index.js',
output: {
filename: 'demo.umd.js',
path: path.resolve(__dirname, 'dist'),
library: "moduleDemo",
// 指定输出的模块规范
libraryTarget: "umd"
},
// for better code inspection, we use "development" mode here.
mode: "development"
};

用以上配置文件执行编译后,查看一下编译输出的文件,对于我们所关心的异步import的语法,webpack是编译成如下的代码:

1
2
3
function jsonpScriptSrc(chunkId) {
return __webpack_require__.p + "" + chunkId + ".demo.umd.js"
}

这个方法在哪里用呢?可以找到,是在下面这个方法__webpack_require__使用:

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
// The require function
function __webpack_require__(moduleId) {
// Check if module is in cache
if (installedModules[moduleId]) {
return installedModules[moduleId].exports;
}
// Create a new module (and put it into the cache)
var module = installedModules[moduleId] = {
i: moduleId,
l: false,
exports: {}
};
// Execute the module function
modules[moduleId].call(module.exports, module, module.exports, __webpack_require__)
// Flag the module as loaded
module.l = true;
// Return the exports of the module
return module.exports;
}
// This file contains only the entry chunk.
// The chunk loading function for additional chunks
__webpack_require__.e = function requireEnsure(chunkId) {
var promises = [];
// JSONP chunk loading for javascript
var installedChunkData = installedChunks[chunkId];
if (installedChunkData !== 0) { // 0 means "already installed".
// a Promise means "currently loading".
if (installedChunkData) {
promises.push(installedChunkData[2]);
} else {
// setup Promise in chunk cache
var promise = new Promise(function(resolve, reject) {
installedChunkData = installedChunks[chunkId] = [resolve, reject];
});
promises.push(installedChunkData[2] = promise);
// start chunk loading
var script = document.createElement('script');
var onScriptComplete;
script.charset = 'utf-8';
script.timeout = 120;
if (__webpack_require__.nc) {
script.setAttribute("nonce", __webpack_require__.nc);
}
script.src = jsonpScriptSrc(chunkId);
// create error before stack unwound to get useful stacktrace later
var error = new Error();
onScriptComplete = function(event) {
// avoid mem leaks in IE.
script.onerror = script.onload = null;
clearTimeout(timeout);
var chunk = installedChunks[chunkId];
if (chunk !== 0) {
if (chunk) {
var errorType = event && (event.type === 'load' ? 'missing' : e
var realSrc = event && event.target && event.target.src; error.message = 'Loading chunk ' + chunkId + ' failed.\n(' + er error.name = 'ChunkLoadError'; error.type = errorType; error.request = realSrc; chunk[1](error);
}
installedChunks[chunkId] = undefined;
}
};
var timeout = setTimeout(function() {
onScriptComplete({
type: 'timeout',
target: script
});
}, 120000);
script.onerror = script.onload = onScriptComplete;
document.head.appendChild(script);
}
}
return Promise.all(promises);
};

可以看到,webpack编译出来的代码,对于异步import的模块,是编译为动态创建script标签,加载对应分割出来的文件。

如果我们的组件输出的是这种代码,当应用引入我们的依赖时,会出现什么情况?

可以看到加载分割文件失败了,原因是:

  • 所希望加载的分割文件,在宿主应用中并没有

要解决这个问题,可以通过在宿主应用中,使用类似copy-webpack-plugin这种插件,将node_modules下的依赖文件拷贝到运行目录中解决。但是这样的方案并不优雅:使用个第三方依赖,还要在编译构建的脚本中增加配置,也太麻烦啰。

rollup

rollup 和 webpack的最大不同之处在于,rollup更适合于用来打包js库[2]。

rollup可以支持将js库打包为es module(esm)格式的js。

下面我们来试试使用rollup来打包我们的js库。

rollup配置文件大概长这个样子:

1
2
3
4
5
6
7
8
9
//rollup.config.js
module.exports = {
input: 'src/index.js',
output: {
// 设置输出格式为esm
format: 'esm',
dir: "dist-esm"
}
};

看看rollup打包后的文件长什么样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function syncDep() {
console.log("[sync-dep] do some job.");
}

// sync import

function asyncAdd(x, y) {
syncDep();
return import('./async-dep-c29b934b.js').then(({ asyncDep }) => {
asyncDep();
return x + y;
})

}

export { asyncAdd };

啥?初步一看,和我们写的源码没什么区别呀?

这就对了。

因为我们编写源码的时候,用的就是es module的语法(import, export)。

编译目标是es module,那当然就变化不大啰。

细看一下,其实还是有变化的,异步import的文件名变成了编译后的文件名,带上了hash值。

改变组件的入口

使用rollup编译完,在 package.json 中设置main指向rollup打包出来的 es module 格式的文件。

1
2
3
{
"main": "dist-esm/index.js"
}

或者,除了指定main外,也可以指定module字段,也有同样的效果。

1
2
3
{
"module": "dist-esm/index.js"
}

运行效果

这次没有报错了,从网络请求中,也可以看到有一个正常的分割文件的请求,经过查看,这个分割文件正是我们的js库中希望分割的那部分代码。

使用webpack bundle analyzer分析一下宿主应用的模块组成:

可以看到最右边的 0.app.js正是我们希望分割的async-dep模块啰。大功告成。

总结

若希望你开发的js模块,包含按需异步加载的代码,使得宿主应用使用时,能够按需加载,请注意如下几点:

  • js库的源码中使用动态import语法(dynamic import)加载需要按需加载的代码
1
import('./xxx.js').then()
  • 不能使用webpack打包js库

    因为使用webpack打包js库时,对于需要懒加载的资源,webpack是直接生成了动态拼接script标签的代码,而对于library来说,这样的代码对于宿主应用的使用是很麻烦的

  • 使用rollup打包js库(js library)

    使用rollup打包js library的好处是,对于library中需要懒加载的资源,rollup编译为了异步加载chunk的代码,而且用的不是动态拼接script标签的方式,这样有助于宿主应用使用js library时进行分割。

    具体例子,可以看看rollup官网的这个例子:

    rollup dynamic import compilation REPL example

  • 设置正确的模块入口

    package.json 设置入口为es module 标准的js。可以通过指定main或者module字段都可以

  • 宿主应用可以正常使用webpack来编译使用js库的代码

    从实践结果来看,宿主应用使用webpack编译,能够正确的解析出js库中的异步加载依赖,并将其作为宿主应用的分割文件进行异步懒加载,有效提升加载性能

完整参考代码

模块代码参考:

https://github.com/ostinatos/js-playground/tree/master/packages/async-dep-module-demo

宿主应用参考:

https://github.com/ostinatos/js-playground/tree/master/packages/module-host-demo

参考资料

[1] Code-splitting for libraries—bundling for npm with Rollup 1.0

讲解如何使用rollup打包出能按需使用的npm模块

[2] Webpack and Rollup: the same but different

比较了webpack和rollup的不同,以及何时该使用哪一个

场景:

  1. 更改对象属性的引用
  2. 更改对象属性内的属性

需要考虑的因素

对象中的属性是否在传递props前存在

考察:

  • watcher的行为
  • computed 属性的行为

演示代码

请看这里:vue-playground

找到main.js,将以下这句取消注释,将其它App的import 注释掉

1
import App from './object-property/main.vue'

结论总结

Vue中的响应式(reactivity)是个好东西,它简化了代码,实现了前端的数据驱动(data-driven),提升了代码的可维护性。

然而,这个响应式特性也是有不少要注意的地方,特别是对于对象和数组,一不留神就容易掉进“陷阱”:为什么数据变了,界面却没有变化?

这篇文章尝试列举一些针对对象和数组的常见响应式场景,试着分析为什么某些场景下响应式“失效”了,背后的原理是什么。

例子源码

请看这里:vue-playground

基于vue-cli 3生成的vue demo工程。安装完依赖后 npm run serve 就可以运行。

代码结构:

1
2
3
4
5
6
7
8
9
10
11
12
└── src
├── App.vue
├── assets
│   └── logo.png
├── components
│   └── HelloWorld.vue
├── main.js
└── object-reactivity
├── components
│   ├── nested-component.vue
│   └── nested-readonly-component.vue
└── object-reactivity.vue

object-reactivity.vue是入口组件。

nested-component.vue是其中一个子组件,nested-readonly-component.vue是另一个子组件。

两者的区别是nested-readonly-component内直接绑定props进行展示。而nested-component的界面绑定的是一个内部data。

场景描述

要测试的场景并不复杂。在入口组件object-reactivity中定义了data,分别有一个类型为对象的basket,和类型为数组的list.

object-reactivity.vue

1
2
3
4
5
6
7
8
9
10
11
12
data(){
return {
foods:{
basket:{
apple: "apple in a basket"
},
list:[
"potato","tomato", "pizza"
]
}
}
}

两者都作为参数(props),分别传入到nested-component以及nested-readonly-component中。

1
2
<nested-component :basket="foods.basket" :foods="foods.list"></nested-component>
<nested-readonly-component :basket="foods.basket" :foods="foods.list"></nested-readonly-component>

然后我们尝试用不同的方式改变 object-reactivity中的这两个数据,看两个组件实例是否在界面上发生相应的变化。

为了便于在console中进行试验,我们将object-reactivity的foods赋给一个window下的全局变量。

1
2
3
mounted(){
window.foods = this.foods;
}

执行 npm run serve 将工程跑起来,然后在控制台分别尝试如下语句,你能预见界面会发生什么变化吗?

1
2
3
4
5
6
7
8
// 1
window.foods.basket.apple = "new apple in the basket."
// 2
Vue.set(foods.basket, "apple", "new apple");
// 3
window.foods.basket = {apple: "new apple in the basket"};
// 4
Vue.set(foods, "basket", {apple:"new apple"});

原理解释

初始状态

上图展示的是初始情况下3个组件和监控的数据(foods.basket)的关系。请结合源码进行理解。

可以看到三个组件都分别有界面的watcher。watcher负责的是监控数据的变化,按需更新界面(DOM)。

值得注意的是,三个watcher所监控的对象并不尽相同:

  • object-reactivity.vue以及nested-readonly-component的界面watcher所监控的是橙色的数据对象。
  • nested-component的界面watcher所监控的是则是自己内部的data: innerBasket。

可以维持响应式的情况

经过测试,你会发现如下两个语句执行后,界面中三处都发生了相应的变化。

1
2
3
4
// 1
window.foods.basket.apple = "new apple in the basket."
// 2
Vue.set(foods.basket, "apple", "new apple");
界面 响应式维持?
object-reactivity.vue yes
nested-component.vue yes
nested-readonly-component.vue yes

原因是什么?

可以结合上面的图来看,三个界面watcher实际上监控的都是中间橙色的那个对象。

一旦这个对象的属性被修改,就会触发这个对象属性的reactive setter (vue给数据对象注入的,如不了解可以参看我的这篇文章: 深入vue的响应式实现),从而通知三个界面watcher对各自的界面进行修改。

丢失响应式的情况

然而,执行以下两个语句中的任意一个,你会发现,nested-component.vue的响应式丢失了。

1
2
3
4
// 3
window.foods.basket = {apple: "new apple in the basket"};
// 4
Vue.set(foods, "basket", {apple:"new apple"});
界面 响应式维持?
object-reactivity.vue yes
nested-component.vue no
nested-readonly-component.vue yes

怎么解释呢?还是通过一张图解释一下。

从示意图上可以看到,当执行以上语句后:

object-reactivity.vue

object-reactivity.vue的界面watcher以及data的指向都发生了变化,指向了一个新的对象;

nested-readonly-component.vue

nested-readonly-component.vue的界面watcher指向的还是自己的basket属性,然而basket属性指向的是那个新的对象;
这里值得留意的一点是,object-reactivity.vue中的foods.basket发生了变化,因此触发了对nested-readonly-component.vue的属性的重新赋值。而由于nested-readonly-component的界面渲染(也就是render函数)是依赖于basket属性,因此basket属性的变化又触发了render()函数的重新执行,从而界面就得到了更新。

nested-component.vue

nested-component.vue的basket属性也发生了变化(就像nested-readonly-component的情况),也指向了新的对象,然而innerBasket指向的还是原来的对象,而其界面watcher指向的又是innerBasket。这也就可以解释为什么nested-component的响应式丢失了,因为其界面watcher所监控的还是旧的那个对象。

lesson learned

属性对应的数据发生变化,vue会给属性重新赋值

其实本质就是监控数据变化 + 执行render函数

object-reactivity.vue的数据(foods)发生变化,触发object-reactivity.vue的render函数重新执行,自然也就把新的数据作为props赋值给了子组件。

理解界面watcher的指向

响应式的核心是通过给数据注入getter/setter从而让界面作为观察者(watcher)监控到数据的变化,自动更新界面。响应式“丢失”的情况,往往就是界面watcher的指向与预期的数据对象不匹配造成。

使用vue有一段时间,对于响应式(reactivity)这个特性,也是踩过不少坑,自然就想了解一下vue源码中是如何实现的,以求更好的理解和使用这个框架。

在google上搜到了一篇很好的文章,通俗易懂的讲清楚了vue源码中关于响应式的核心实现代码原理。文章在此:

How to Build Your Own Reactivity System

基于此就顺手写了个小原型,感受一下这个实现,代码在此:

https://github.com/ostinatos/reactivity-prototype

这篇文章也就简单总结一下实现这个原型的关键点,以及写了这个原型后本人对vue中提及的响应式的“坑”的一些理解。

实现vue的响应式原型的关键点

原型的关键图示如下:

关键点

使用defineProperty为data注入getter/setter

参考reactify.js的内容。这里深度遍历输入的data对象,对每一个属性都使用defineProperty注入一个getter和setter。

观察者模式 (observer pattern)

vue的响应式机制主要使用了观察者模式,Dep.js定义的Dep类可以理解为一个observable。

在遍历data的时候,会为每个属性都创建一个Dep实例,也就是observable实例。然后通过defineProperty注入的getter/setter中会调用observable实例的方法。

具体来看看Dep类都有哪些关键属性和方法:

一个观察者清单(subscriber list/ observer list)。若有watcher和当前属性关联,则会被加入此观察者清单。注意,虽然说是“清单”,但实际实现的时候用的是集合(Set),目的是重复调用也不会导致重复。

notify()做的就是循环调用观察者清单的update()方法。这是观察者模式的典型套路了。

depend()方法的作用是将当前被估值的watcher加入观察者清单。

watcher

vue中模版内的表达式,或者计算属性,底层对应的应该就是watcher了。

Dep.target

这个Dep类的静态变量,作用是纪录当前正在被估值的watcher(们)。

注意,在实际的vue代码(本文参考的源码版本是2.5.17)中,这个变量还额外用了一个辅助的stack来管理。而如果只是实现响应式的原型,可以不用stack也可以的。

至于为什么要用stack,segmentfault这个问答有分析,还是比较有道理的:

Vue Dep.target为什么需要targetStack来管理?

对vue的响应式机制的“坑”的理解

vue的官方文档中,专门花了一个章节讲述vue的响应式机制的原理和注意事项:

https://cn.vuejs.org/v2/guide/reactivity.html

当中提到的要点:

  • 不能动态添加根级响应式属性 (root-level reactive property)

  • 对于提早声明了的响应式属性,若需要添加删除属性,需要使用vue提供的set API

这两点,本质都是由于vue要实现属性的响应式,依赖的是给属性注入的reactiveGetter和reactiveSetter。

不能动态添加根级响应式属性

例子,引用自vue的官方文档:

1
2
3
4
5
6
7
8
9
10
var vm = new Vue({
data:{
a:1
}
})

// vm.a 是响应的

vm.b = 2
// vm.b 是非响应的

原因在于,对于根级响应式属性,reactiveGetter和reactiveSetter的注入是通过在初始化vue组件实例的时候,遍历data来实现的。而当实例被初始化后,就没有时机再去监控根级响应式属性的增加。因此就有了第一条要点。

使用vue提供的API添加删除属性

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var vm = new Vue ({
data: {
someObj:{
a:"foo"
}
}
})

//vm.someObj.a是响应式的。

Vue.set(vm.someObj, 'b', 'bar');
// vm.someObj.b 也是响应式的。

vm.someObj.c = "no";
// vm.someObj.c 是非响应式的。

这条规则背后的原因是,若不通过vue提供的API来进行添加属性,则vue框架无法给新增的属性添加reactiveGetter和reactiveSetter,也就无法实现响应式。

Spring Boot + Mybatis + oracle下如何批量插入数据并返回id?
How to batch insert data and get inserted id in spring boot + mybatis + oracle

1. 需求

一个很典型的场景:批量插入数据,并获得这些数据的id
在spring boot + mybatis + oracle这样的组合中如何实现?如何实现才是性能最好的方式?

2. 关键点

获取id并赋值到pojo

  • 使用mybatis的selectKey标签
    需要单独的select语句获取sequence值

    1
    2
    3
    4
    5
    6
    7
    <insert id="insertPerson" parameterType="mount.olympus.prometheus.model.Person"
    keyColumn="pid" keyProperty="pid">
    <selectKey keyProperty="pid" resultType="long" order="BEFORE">
    select person_s.nextval from dual
    </selectKey>
    insert into person(pid, name) values(#{pid, jdbcType=NUMERIC}, #{name, jdbcType=VARCHAR})
    </insert>
  • 使用mybatis的useGeneratedKeys=”true”
    这种方式在insert语句中可以直接使用sequence.nextval获取,不需要单独的select语句

    1
    2
    3
    <insert id="insertPerson" parameterType="mount.olympus.prometheus.model.Person" keyColumn="pid" keyProperty="pid" useGeneratedKeys="true">
    insert into person(pid, name) values(person_s.nextval, #{name, jdbcType=VARCHAR})
    </insert>

分析:

单独的select语句会成为额外的开销。

批量插入数据

  • 循环调用mybatis mapper接口方法

  • 拼接一个大sql,只调用一次mapper接口方法
    这种方式对于oracle无法获取每条插入数据的id

  • mybatis的batch模式
    org.apache.ibatis.session.ExecutorType.BATCH
    ExecutorType有三种模式:SIMPLE, REUSE, BATCH
    默认是SIMPLE模式。
    打开session的时候可以指定此参数,经过试验,使用了BATCH模式的优势在于每次执行插入语句时会重用相同的preparedStatement,而SIMPLE模式则每次都会新建preparedStatement。

分析:

由于需求是要获取每条插入数据的id,因此拼接大sql的方式并不可行。
虽然按这篇文章的说法,拼接大sql的性能会较好,但是由于不符合本文的需求,因此不在此讨论。

事务

使用spring管理的事务方式,可以实现在一个事务内重用mybatis的sqlSession对象的效果,避免了占用过多数据库连接导致异常。

3. 测试结果:

batch operation performance result

说明:

可见最优的方案是使用mybatis的batch模式 + 采用useGeneratedKeys=”true”的方式。
batch模式由于重用了preparedStatement并进行批量提交,因此性能较好。
而不使用独立的select语句查询id则可以进一步提高性能。

4. 建议解决方案:

  • 使用mybatis的batch模式 + 采用useGeneratedKeys=”true”的方式
  • 由于非批量的操作不一定需要batch模式,所以可以采用默认使用simple模式的sqlSession对象。而针对批量操作,可以独立定义一个为batch操作而设的sqlSession对象。
    在spring配置文件中的配置方式如下:
    1
    2
    3
    4
    5
    <!-- sql session for batch operations. -->
    <bean id="sqlSessionForBatch" class="org.mybatis.spring.SqlSessionTemplate">
    <constructor-arg index="0" ref="sqlSessionFactory" />
    <constructor-arg index="1" value="BATCH" />
    </bean>

sqlSessionFactory定义如下:

1
2
3
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
<property name="dataSource" ref="dataSource" />
</bean>

  • 在服务实现层使用batch模式的sqlSession对象,调用mybatis的mapper接口进行数据库操作。样例代码片段:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    @Named("personService")
    public class PersonBatchService implements IPersonService {

    @Inject
    private SqlSession sqlSessionForBatch;

    @Override
    @Transactional
    public List<String> batchInsertPerson() {
    ...

    IPersonMapper mapper = sqlSessionForBatch.getMapper(IPersonMapper.class);

    for(Person p:pl){
    mapper.insertPerson(p);
    }

    ...
    }

    ...
    }

5. 实现注意点:

  • spring boot中配置log level
    在项目classpath中加入一个application.properties,然后写上如下内容:
    1
    2
    3
    4
    debug=true
    logging.level.org.mybatis=DEBUG
    logging.level.org.apache.ibatis=DEBUG
    logging.level.mount.olympus.prometheus=DEBUG

这样就可以按需要查看相应的日志。

  • mybatis的@Flush注解
    org.apache.ibatis.annotations.Flush
    Flush注解的作用是在BATCH模式下,调用注解了Flush的mapper方法可以将存储在JDBC driver中的batch statement执行。
    所以一般来说循环结束时最后调用flush即可。当然也可以根据记录数批量flush。

6. 参考代码:

用到的sequence:

1
2
3
4
5
6
create sequence TESTUSER.PERSON_S
minvalue 1
maxvalue 9999999999999999999999999999
start with 551420
increment by 1
cache 20;

  • 需要额外使用oracle jdbc driver,下载一个加到项目classpath即可
  • spring boot的入口程序
    mount.olympus.prometheus.Application
    run as java application即可。

  • 接口测试地址:
    http://localhost:8080/person/batch
    方法:post
    参数:无

references: