Justin Duke

Migrating from moment to date-fns

I got a lot of positive feedback from my recent article on how I cut my webpack bundle size in half, and a common refrain was how folks found themselves in the same position as I did in terms of deciding to migrate off of moment.js.

A handful of folks suggested date-fns as an alternative. date-fns has a relatively clumsy name but offered excatly what I was looking for:

  • Modularity and tree-shaking compatability.
  • Use of native dates
  • TypeScript support

So I decided to take the plunge and try migrating: the surface area of my use of moment wasn’t that large, so I hoped it would be relatively painless.

(Spoiler alert: it was.)

Here’s what the diff looked like:

assets/components/DateTimePicker.vue CHANGED
@@ -7,16 +7,14 @@
7
7
</template>
8
8
9
9
<script>
10
- import moment from 'moment';
10
+ import format from 'date-fns/format';
11
11
12
12
export default {
13
13
props: ['value'],
14
14
15
15
computed: {
16
16
formattedValue() {
17
- return this.value
17
+ return this.value ? format(this.value, 'MM/DD/YYYY @ h:mm a') : null;
18
- ? moment(this.value).format('MM/DD/YYYY @ h:mm a')
19
- : null;
20
18
},
21
19
},
22
20
assets/components/DraftLoadingNotice.vue CHANGED
@@ -4,7 +4,7 @@
4
4
You worked on a
5
5
<span v-if="latestDraft.subject">draft entitled <strong>{{ latestDraft.subject }}</strong></span>
6
6
<span v-else>untitled draft</span>
7
- {{ latestDraftAgo }}.
7
+ {{ latestDraftAgo }} ago.
8
8
<a href="#" @click="loadDraft"><strong>Continue writing</strong></a>.
9
9
</div>
10
10
<div class="actions">
@@ -16,7 +16,7 @@
16
16
</template>
17
17
18
18
<script>
19
- import moment from 'moment';
19
+ import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
20
20
21
21
import { mapState } from 'vuex';
22
22
import { DRAFTS } from 'store/state_types';
@@ -41,7 +41,7 @@
41
41
computed: {
42
42
latestDraftAgo() {
43
43
return this.latestDraft
44
- ? moment(this.latestDraft.modification_date).fromNow()
44
+ ? distanceInWordsToNow(this.latestDraft.modification_date)
45
45
: null;
46
46
},
47
47
latestDraft() {
assets/screens/Write.vue CHANGED
@@ -99,7 +99,7 @@
99
99
</template>
100
100
101
101
<script lang="ts">
102
- import moment from 'moment';
102
+ import distanceInWordsToNow from 'date-fns/distance_in_words_to_now';
103
103
import { mapState } from 'vuex';
104
104
105
105
import { USER } from 'store/state_types';
@@ -138,10 +138,11 @@ export default {
138
138
body: computeWorkingEmail('body'),
139
139
draftId: computeWorkingEmail('draftId'),
140
140
timeUntilPublish() {
141
- if (moment.utc(this.publishDate) < moment()) {
141
+ const utcOffset = (new Date()).getTimezoneOffset() * 60 * 1000;
142
+ if (Date.parse(this.publishDate) - utcOffset < Date.now()) {
142
143
return 'immediately';
143
144
}
144
- return moment.utc(this.publishDate).fromNow();
145
+ return distanceInWordsToNow(Date.parse(this.publishDate) - utcOffset);
145
146
},
146
147
needsBilling() {
147
148
return this.user.settings.needs_billing_information;
assets/utils.ts CHANGED
@@ -1,6 +1,7 @@
1
1
import each from 'lodash/each';
2
2
import marked from 'marked';
3
- import moment from 'moment';
3
+ import format from 'date-fns/format';
4
+ import differenceInDays from 'date-fns/difference_in_days';
4
5
import Noty from 'noty';
5
6
6
7
declare const SITE_URL;
@@ -71,7 +72,7 @@ const extractLinksFromMarkdown = markdownString => {
71
72
};
72
73
73
74
/* Given a raw date timestamp, format it nicely. */
74
- const formatDate = date => moment(date).format('MM/DD/YYYY');
75
+ const formatDate = date => format(date, 'MM/DD/YYYY');
75
76
76
77
const renderMarkdownWithAllLinksAsTargetBlank = markdownString => {
77
78
// Create a custom renderer to always use target=_blank.
@@ -83,7 +84,10 @@ const renderMarkdownWithAllLinksAsTargetBlank = markdownString => {
83
84
return marked(markdownString, { renderer });
84
85
};
85
86
86
- const daysSince = dateString => moment().diff(moment(dateString), 'days');
87
+ const daysSince = dateString => differenceInDays(
88
+ Date.now(),
89
+ Date.parse(dateString),
90
+ );
87
91
88
92
const siteTitle = 'Buttondown';
89
93
package.json CHANGED
@@ -41,6 +41,7 @@
41
41
"cross-env": "^3.1.4",
42
42
"cross-spawn": "^5.0.1",
43
43
"css-loader": "^0.26.2",
44
+ "date-fns": "^1.28.5",
44
45
"diff-match-patch": "^1.0.0",
45
46
"email-validator": "^1.0.7",
46
47
"emoji-regex": "^6.4.2",
@@ -85,7 +86,6 @@
85
86
"marked": "^0.3.6",
86
87
"md5": "^2.2.1",
87
88
"mocha": "^3.2.0",
88
- "moment": "^2.18.1",
89
89
"nightwatch": "^0.9.12",
90
90
"node-sass": "latest",
91
91
"noty": "^3.1.0-beta",
webpack-stats.json CHANGED
@@ -1 +1 @@
1
- {"status":"done","chunks":{"main":[{"name":"main-fac5e0e7c6f62e7e200c.js","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/main-fac5e0e7c6f62e7e200c.js"}]},"error":"ModuleError","message":"\n/Users/jmduke/workspaces/buttondown/assets/store/actions.js\n 61:5 warning Unexpected console statement no-console\n 61:30 error Missing semicolon semi\n\nāœ– 2 problems (1 error, 1 warning)\n"}
1
+ {"status":"done","publicPath":"/static/webpack_bundles/","chunks":{"zxcvbn":[{"name":"zxcvbn-376b5b3616c0f1ab29a7.js","publicPath":"/static/webpack_bundles/zxcvbn-376b5b3616c0f1ab29a7.js","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/zxcvbn-376b5b3616c0f1ab29a7.js"}],"flatpickr":[{"name":"flatpickr-376b5b3616c0f1ab29a7.js","publicPath":"/static/webpack_bundles/flatpickr-376b5b3616c0f1ab29a7.js","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/flatpickr-376b5b3616c0f1ab29a7.js"}],"main":[{"name":"main-376b5b3616c0f1ab29a7.js","publicPath":"/static/webpack_bundles/main-376b5b3616c0f1ab29a7.js","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/main-376b5b3616c0f1ab29a7.js"}],"miscellany":[{"name":"miscellany-376b5b3616c0f1ab29a7.js","publicPath":"/static/webpack_bundles/miscellany-376b5b3616c0f1ab29a7.js","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/miscellany-376b5b3616c0f1ab29a7.js"},{"name":"miscellany.css","publicPath":"/static/webpack_bundles/miscellany.css","path":"/Users/jmduke/workspaces/buttondown/assets/webpack_bundles/miscellany.css"}]},"error":"unknown-error","message":"(3,20): error TS2307: Cannot find module 'moment'."}
yarn.lock CHANGED
@@ -2099,6 +2099,10 @@ data-uri-to-buffer@1:
2099
2099
version "1.2.0"
2100
2100
resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-1.2.0.tgz#77163ea9c20d8641b4707e8f18abdf9a78f34835"
2101
2101
2102
+ date-fns@^1.28.5:
2103
+ version "1.28.5"
2104
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.28.5.tgz#257cfc45d322df45ef5658665967ee841cd73faf"
2105
+
2102
2106
date-now@^0.1.4:
2103
2107
version "0.1.4"
2104
2108
resolved "https://registry.yarnpkg.com/date-now/-/date-now-0.1.4.tgz#eaf439fd4d4848ad74e5cc7dbef200672b9e345b"
@@ -5142,10 +5146,6 @@ mocha@^3.2.0:
5142
5146
mkdirp "0.5.1"
5143
5147
supports-color "3.1.2"
5144
5148
5145
- moment@^2.18.1:
5146
- version "2.18.1"
5147
- resolved "https://registry.yarnpkg.com/moment/-/moment-2.18.1.tgz#c36193dd3ce1c2eed2adb7c802dbbc77a81b1c0f"
5148
-
5149
5149
ms@0.7.1:
5150
5150
version "0.7.1"
5151
5151
resolved "https://registry.yarnpkg.com/ms/-/ms-0.7.1.tgz#9cd13c03adbff25b65effde7ce864ee952017098"

As you can see, nothing too crazy. A couple notes:

  • Half of the stuff is straight-up API parity, like format adn distance_in_words.
  • The only tricky part was replacing moment.utc, since it didn’t seem like date-fns had that much timezone support. (It’s also entirely possible there is a much easier way to do this that eluded me.)
  • Buttondown’s bundle is now 150 kilobytes thinner. šŸŽ‰

So, yeah! Check out date-fns. It’s very nice. I would still like to take another stab at unifying Buttondown’s use of datetime (just to make migrations like this easier in the future), but I’m super happy with how pleasant (and rewarding) this process was.

Liked this post? You should subscribe to my newsletter and follow me on Twitter.
Ā© 2017 Justin Duke ā€¢ All rights reserved ā€¢ I hope you have a nice day.