diff --git a/docs/markdown/v2-progress-tracking.md b/docs/markdown/v2-progress-tracking.md
index 9dccc54a9..81683c277 100644
--- a/docs/markdown/v2-progress-tracking.md
+++ b/docs/markdown/v2-progress-tracking.md
@@ -1,4 +1,4 @@
-## Vant 2.0 改动一览
+# Vant 2.0 改动一览
## 不兼容更新
@@ -49,6 +49,8 @@
## 新特性
+- 新增`Skeleton`骨架屏组件
+
### Sku
- 新增`preview-open`事件
diff --git a/docs/src/demo-entry.js b/docs/src/demo-entry.js
index 6f0a73fc6..b69130510 100644
--- a/docs/src/demo-entry.js
+++ b/docs/src/demo-entry.js
@@ -40,6 +40,7 @@ export default {
'rate': () => wrapper(import('../../packages/rate/demo'), 'rate'),
'search': () => wrapper(import('../../packages/search/demo'), 'search'),
'sidebar': () => wrapper(import('../../packages/sidebar/demo'), 'sidebar'),
+ 'skeleton': () => wrapper(import('../../packages/skeleton/demo'), 'skeleton'),
'sku': () => wrapper(import('../../packages/sku/demo'), 'sku'),
'slider': () => wrapper(import('../../packages/slider/demo'), 'slider'),
'stepper': () => wrapper(import('../../packages/stepper/demo'), 'stepper'),
diff --git a/docs/src/doc.config.js b/docs/src/doc.config.js
index 6a009488e..ac339f471 100644
--- a/docs/src/doc.config.js
+++ b/docs/src/doc.config.js
@@ -233,6 +233,10 @@ module.exports = {
path: '/progress',
title: 'Progress 进度条'
},
+ {
+ path: '/skeleton',
+ title: 'Skeleton 骨架屏'
+ },
{
path: '/steps',
title: 'Steps 步骤条'
@@ -540,6 +544,10 @@ module.exports = {
path: '/progress',
title: 'Progress'
},
+ {
+ path: '/skeleton',
+ title: 'Skeleton'
+ },
{
path: '/steps',
title: 'Steps'
diff --git a/docs/src/docs-entry.js b/docs/src/docs-entry.js
index 5e9291552..ba4bb3c31 100644
--- a/docs/src/docs-entry.js
+++ b/docs/src/docs-entry.js
@@ -89,6 +89,8 @@ export default {
'search.zh-CN': () => import('../../packages/search/zh-CN.md'),
'sidebar.en-US': () => import('../../packages/sidebar/en-US.md'),
'sidebar.zh-CN': () => import('../../packages/sidebar/zh-CN.md'),
+ 'skeleton.en-US': () => import('../../packages/skeleton/en-US.md'),
+ 'skeleton.zh-CN': () => import('../../packages/skeleton/zh-CN.md'),
'sku.en-US': () => import('../../packages/sku/en-US.md'),
'sku.zh-CN': () => import('../../packages/sku/zh-CN.md'),
'slider.en-US': () => import('../../packages/slider/en-US.md'),
diff --git a/packages/index.less b/packages/index.less
index 9ef92ef24..d902afd5e 100644
--- a/packages/index.less
+++ b/packages/index.less
@@ -29,6 +29,7 @@
@import './progress/index';
@import './sidebar/index';
@import './sidebar-item/index';
+@import './skeleton/index';
@import './slider/index';
@import './stepper/index';
@import './swipe/index';
diff --git a/packages/index.ts b/packages/index.ts
index 3005a095d..3f425d56e 100644
--- a/packages/index.ts
+++ b/packages/index.ts
@@ -53,6 +53,7 @@ import Row from './row';
import Search from './search';
import Sidebar from './sidebar';
import SidebarItem from './sidebar-item';
+import Skeleton from './skeleton';
import Sku from './sku';
import Slider from './slider';
import Step from './step';
@@ -131,6 +132,7 @@ const components = [
Search,
Sidebar,
SidebarItem,
+ Skeleton,
Sku,
Slider,
Step,
@@ -218,6 +220,7 @@ export {
Search,
Sidebar,
SidebarItem,
+ Skeleton,
Sku,
Slider,
Step,
diff --git a/packages/skeleton/demo/index.vue b/packages/skeleton/demo/index.vue
new file mode 100644
index 000000000..089597493
--- /dev/null
+++ b/packages/skeleton/demo/index.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

+
+
{{ $t('title') }}
+
{{ $t('desc') }}
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/skeleton/en-US.md b/packages/skeleton/en-US.md
new file mode 100644
index 000000000..7e0709304
--- /dev/null
+++ b/packages/skeleton/en-US.md
@@ -0,0 +1,62 @@
+## Skeleton
+
+### Install
+
+``` javascript
+import { Skeleton } from 'vant';
+
+Vue.use(Skeleton);
+```
+
+### Usage
+
+#### Basic Usage
+
+```html
+
+```
+
+#### Show Avatar
+
+```html
+
+```
+
+#### Show Children
+
+```html
+
+ Content
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ loading: true
+ }
+ },
+ mounted() {
+ this.loading = false;
+ }
+};
+```
+### API
+
+| Attribute | Description | Type | Default |
+|------|------|------|------|
+| row | Row count | `Number` | `0` |
+| row-width | Row width, can be array | `Number | String | Array` | `100%` |
+| title | Whether to show title placeholder | `Boolean` | `false` |
+| title-width | Title width | `Number | String` | `40%` |
+| avatar | Whether to show avatar placeholder | `Boolean` | `false` |
+| avatar-size | Size of avatar placeholder | `Number | String` | `32px` |
+| avatar-shape | Shape of avatar placeholder,can be set to `square` | `String` | `round` |
+| loading | Whether to show skeleton,pass `false` to show child component | `Boolean` | `true` |
+| animate | Whether to enable animation | `Boolean` | `true` |
diff --git a/packages/skeleton/index.less b/packages/skeleton/index.less
new file mode 100644
index 000000000..fa9925387
--- /dev/null
+++ b/packages/skeleton/index.less
@@ -0,0 +1,54 @@
+@import '../style/var';
+
+.van-skeleton {
+ display: flex;
+ padding: 0 15px;
+
+ &__avatar {
+ flex-shrink: 0;
+ margin-right: 15px;
+ background-color: @active-color;
+
+ &--round {
+ border-radius: 100%;
+ }
+ }
+
+ &__content {
+ width: 100%;
+ }
+
+ &__avatar + &__content {
+ padding-top: 8px;
+ }
+
+ &__row,
+ &__title {
+ height: 16px;
+ background-color: @active-color;
+ }
+
+ &__title {
+ margin: 0;
+ }
+
+ &__row {
+ &:not(:first-child) {
+ margin-top: 12px;
+ }
+ }
+
+ &__title + &__row {
+ margin-top: 20px;
+ }
+
+ &--animate {
+ animation: van-skeleton-blink 1.2s ease-in-out infinite;
+ }
+}
+
+@keyframes van-skeleton-blink {
+ 50% {
+ opacity: .6;
+ }
+}
diff --git a/packages/skeleton/index.tsx b/packages/skeleton/index.tsx
new file mode 100644
index 000000000..b893b43db
--- /dev/null
+++ b/packages/skeleton/index.tsx
@@ -0,0 +1,122 @@
+import { use } from '../utils';
+import { inherit } from '../utils/functional';
+import { isNumber } from '../utils/validate/number';
+
+// Types
+import { CreateElement, RenderContext } from 'vue/types';
+import { DefaultSlots } from '../utils/use/sfc';
+
+export type SkeletonProps = {
+ row: number;
+ title?: boolean;
+ avatar?: boolean;
+ loading: boolean;
+ animate: boolean;
+ avatarSize: string;
+ avatarShape: 'square' | 'round';
+ titleWidth: number | string;
+ rowWidth: number | string | (number | string)[];
+};
+
+const [sfc, bem] = use('skeleton');
+const DEFAULT_ROW_WIDTH = '100%';
+const DEFAULT_LAST_ROW_WIDTH = '60%';
+
+function suffixPx(value: string | number): string {
+ value = String(value);
+ return isNumber(value) ? `${value}px` : value;
+}
+
+function Skeleton(
+ h: CreateElement,
+ props: SkeletonProps,
+ slots: DefaultSlots,
+ ctx: RenderContext
+) {
+ if (!props.loading) {
+ return slots.default && slots.default();
+ }
+
+ function Title() {
+ if (props.title) {
+ return
;
+ }
+ }
+
+ function Rows() {
+ const Rows = [];
+ const { rowWidth } = props;
+
+ function getRowWidth(index: number) {
+ if (rowWidth === DEFAULT_ROW_WIDTH && index === props.row - 1) {
+ return DEFAULT_LAST_ROW_WIDTH;
+ }
+
+ if (Array.isArray(rowWidth)) {
+ return rowWidth[index];
+ }
+
+ return rowWidth;
+ }
+
+ for (let i = 0; i < props.row; i++) {
+ Rows.push();
+ }
+
+ return Rows;
+ }
+
+ function Avatar() {
+ if (props.avatar) {
+ const size = suffixPx(props.avatarSize);
+ return (
+
+ );
+ }
+ }
+
+ return (
+
+ {Avatar()}
+
+ {Title()}
+ {Rows()}
+
+
+ );
+}
+
+Skeleton.props = {
+ row: Number,
+ title: Boolean,
+ avatar: Boolean,
+ loading: {
+ type: Boolean,
+ default: true
+ },
+ animate: {
+ type: Boolean,
+ default: true
+ },
+ avatarSize: {
+ type: String,
+ default: '32px'
+ },
+ avatarShape: {
+ type: String,
+ default: 'round'
+ },
+ titleWidth: {
+ type: [Number, String],
+ default: '40%'
+ },
+ rowWidth: {
+ type: [Number, String, Array],
+ default: DEFAULT_ROW_WIDTH
+ }
+};
+
+export default sfc(Skeleton);
diff --git a/packages/skeleton/test/__snapshots__/demo.spec.js.snap b/packages/skeleton/test/__snapshots__/demo.spec.js.snap
new file mode 100644
index 000000000..ba1e1dadb
--- /dev/null
+++ b/packages/skeleton/test/__snapshots__/demo.spec.js.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders demo correctly 1`] = `
+
+`;
diff --git a/packages/skeleton/test/__snapshots__/index.spec.js.snap b/packages/skeleton/test/__snapshots__/index.spec.js.snap
new file mode 100644
index 000000000..2d6923cc0
--- /dev/null
+++ b/packages/skeleton/test/__snapshots__/index.spec.js.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`avatar shape 1`] = `
+
+`;
+
+exports[`disable animate 1`] = `
+
+`;
+
+exports[`render chidren 1`] = `Content
`;
+
+exports[`row-width array 1`] = `
+
+`;
diff --git a/packages/skeleton/test/demo.spec.js b/packages/skeleton/test/demo.spec.js
new file mode 100644
index 000000000..d647cfabc
--- /dev/null
+++ b/packages/skeleton/test/demo.spec.js
@@ -0,0 +1,4 @@
+import Demo from '../demo';
+import demoTest from '../../../test/demo-test';
+
+demoTest(Demo);
diff --git a/packages/skeleton/test/index.spec.js b/packages/skeleton/test/index.spec.js
new file mode 100644
index 000000000..dd3790a1f
--- /dev/null
+++ b/packages/skeleton/test/index.spec.js
@@ -0,0 +1,45 @@
+import { mount } from '../../../test/utils';
+import Skeleton from '..';
+
+test('row-width array', () => {
+ const wrapper = mount(Skeleton, {
+ propsData: {
+ row: 4,
+ rowWidth: ['100%', 30, '5rem']
+ }
+ });
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('render chidren', () => {
+ const wrapper = mount({
+ template: `
+
+ Content
+
+ `,
+ components: { Skeleton }
+ });
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('avatar shape', () => {
+ const wrapper = mount(Skeleton, {
+ propsData: {
+ avatar: true,
+ avatarSize: 20,
+ avatarShape: 'square'
+ }
+ });
+ expect(wrapper).toMatchSnapshot();
+});
+
+test('disable animate', () => {
+ const wrapper = mount(Skeleton, {
+ propsData: {
+ row: 1,
+ aniamte: false
+ }
+ });
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/packages/skeleton/zh-CN.md b/packages/skeleton/zh-CN.md
new file mode 100644
index 000000000..a833ee5ed
--- /dev/null
+++ b/packages/skeleton/zh-CN.md
@@ -0,0 +1,69 @@
+## Skeleton 骨架屏
+
+### 使用指南
+
+``` javascript
+import { Skeleton } from 'vant';
+
+Vue.use(Skeleton);
+```
+
+### 代码演示
+
+#### 基础用法
+
+通过`title`属性显示标题占位图,通过`row`属性配置占位段落行数
+
+```html
+
+```
+
+#### 显示头像
+
+通过`avatar`属性显示头像占位图
+
+```html
+
+```
+
+#### 展示子组件
+
+将`loading`属性设置成`false`表示内容加载完成,此时会隐藏占位图,并显示`Skeleton`的子组件
+
+```html
+
+ 实际内容
+
+```
+
+```js
+export default {
+ data() {
+ return {
+ loading: true
+ }
+ },
+ mounted() {
+ this.loading = false;
+ }
+};
+```
+
+### API
+
+| 参数 | 说明 | 类型 | 默认值 | 版本 |
+|------|------|------|------|------|
+| row | 段落占位图行数 | `Number` | `0` | - |
+| row-width | 段落占位图宽度,可传数组来设置每一行的宽度 | `Number | String | Array` | `100%` | - |
+| title | 是否显示标题占位图 | `Boolean` | `false` | - |
+| title-width | 标题占位图宽度 | `Number | String` | `40%` | - |
+| avatar | 是否显示头像占位图 | `Boolean` | `false` | - |
+| avatar-size | 头像占位图大小 | `Number | String` | `32px` | - |
+| avatar-shape | 头像占位图形状,可选值为`square` | `String` | `round` | - |
+| loading | 是否显示占位图,传`false`时会展示子组件内容 | `Boolean` | `true` | - |
+| animate | 是否开启动画 | `Boolean` | `true` | - |