Skip to content

Commit

Permalink
perf: replace Svelte with vanilla JS (#381)
Browse files Browse the repository at this point in the history
  • Loading branch information
nolanlawson committed Dec 17, 2023
1 parent 750e849 commit 5699285
Show file tree
Hide file tree
Showing 27 changed files with 1,723 additions and 1,012 deletions.
2 changes: 0 additions & 2 deletions .gitignore
Expand Up @@ -123,8 +123,6 @@ dist
/trimEmojiData.js.map
/trimEmojiData.cjs
/trimEmojiData.cjs.map
/svelte.js
/svelte.js.map

/docs-tmp
/ts-tmp
Expand Down
29 changes: 23 additions & 6 deletions CONTRIBUTING.md
Expand Up @@ -59,15 +59,32 @@ Build the GitHub Pages docs site:

Some explanations of why the code is structured the way it is, in case it's confusing.

### Why is it one big Svelte component?
### Why a custom framework?

When you build Svelte components with `customElement: true`, it makes _each individual component_ into a web component. This can be bad for perf reasons (lots of repetition, [constructible stylesheets](https://wicg.github.io/construct-stylesheets/) aren't a thing yet, event and prop overhead) as well as correctness reasons (e.g. I want an `<li>` inside of a `<ul>`, not a `<custom-element>` with a shadow DOM and the `<li>` inside of it).
It was [a good learning exercise](https://nolanlawson.com/2023/12/02/lets-learn-how-modern-javascript-frameworks-work-by-building-one/), and it reduced the bundle size quite a bit to switch from Svelte to a custom framework. Plus, `emoji-picker-element` no longer needs to keep
up with breaking changes in Svelte or the tools in the Svelte ecosystem (e.g. Rollup and Jest plugins).

So for now: it's one big component.
### What are some of the quirks of the custom framework?

### Why use svelte-preprocess?
The framework mostly gets the job done, but I took a few shortcuts since we didn't need all the possible bells and whistles. Here is a brief description.

Since it's one big component, it's more readable if we split up the HTML/CSS/JS. Plus, we can lint the JS more easily that way. Plus, I like SCSS.
First, all the DOM nodes and update functions for those nodes are kept in-memory via a `WeakMap` where the key is the `state`. There's one `state` per instance of the `Picker.js` Svelte-esque component. So when the instance is GC'ed, everything related to the DOM and update functions should be GC'ed. (The exception is the global `parseCache`, which only contains the clone-able `template` and bindings for each unique `tokens` array, which is unique per `html` tag template literal. These templates/bindings never changes per component instance, so it makes sense to just parse once and cache them forever, in case the `<emoji-picker>` element itself is constantly unmounted and re-created.)

Second, I took a shortcut, which is that all unique (non-`<template>`) DOM nodes and update functions are keyed off of 1) the unique tokens for the tag template literal plus 2) a unique `key` from the `map` function (if it exists). These are only GC'ed when the whole `state` is GC'ed. So in the worst case, every DOM node for every emoji in the picker is kept in memory (e.g. if you click on every tab button), but this seemed like a reasonable tradeoff for simplicity, plus the perf improvement of avoiding re-rendering the same node when it's unchanged (this is especially important if the reactivity system is kind of chatty, and is constantly setting the same arrays over and over – the framework just notices that all the `children` are the same objects and doesn't re-render). This also works because the `map`ed DOM nodes are not highly dynamic.

Third, all refs and event listeners are only bound once – this just happens to work since most of the event listeners are hoisted (delegated) anyway.

Fourth, `map`ped iterations without a single top-level element are unsupported – this makes updating iterations much easier, since I can just use `Element.replaceChildren()` instead of having to keep bookmark comment nodes or something.

Fifth, the reactivity system is really bare-bones and doesn't check for cycles or avoid wasteful re-renderings or anything. So there's a lot of guardrails to avoid setting the same object over and over to avoid infinite cycles or to avoid excessive re-renders.

Sixth, I tried to get fine-grained reactivity working but gave up, so basically the whole top-level `PickerTemplate.js` function is executed over and over again anytime anything changes. So there are guardrails in place to make sure this isn't expensive (e.g. the caching mechanisms described above).

There's also a long tail of things that aren't supported in the HTML parser, like funky characters like `<` and `=` inside of text nodes, which could confuse the parser (so I just don't support them).

Also, it's assumed that we're using some kind of minifier for the HTML tagged template literals – it would be annoying to have to author `PickerTemplate.js` without any whitespace. So the parser doesn't support comments since those are assumed to be stripped out anyway.

That's about it, there are probably bugs in the framework if you tried to use it for something other than `emoji-picker-element`, but that's fine – it only needs to support one component anyway.

### Why are the built JS files at the root of the project?

Expand All @@ -77,4 +94,4 @@ I could also build a `pkg/` directory and copy the `package.json` into it (this

### Why build two separate bundles?

`picker.js` and `database.js` are designed to be independentally `import`-able. The only way to do this correctly with the right behavior from bundlers like Rollup and Webpack is to create two separate files. Otherwise the bundler would not be able to tree-shake `picker` from `database`.
`picker.js` and `database.js` are designed to be independently `import`-able. The only way to do this correctly with the right behavior from bundlers like Rollup and Webpack is to create two separate files. Otherwise the bundler would not be able to tree-shake `picker` from `database`.
15 changes: 10 additions & 5 deletions README.md
Expand Up @@ -827,17 +827,22 @@ The reason for this is that `Picker` automatically registers itself as a custom

### Within a Svelte project

`emoji-picker-element` is explicitly designed as a custom element, and won't work
as a direct Svelte component. However, if you're already using Svelte 3, then you
can avoid importing Svelte twice by using:
> [!WARNING]
> `emoji-picker-element` is no longer based on Svelte, so importing from `emoji-picker-element/svelte` is now deprecated.
Previously, `emoji-picker-element` was based on Svelte v3/v4, and you could do:

```js
import Picker from 'emoji-picker-element/svelte';
```

`svelte.js` is the same as `picker.js`, except it `import`s Svelte rather than bundling it.
The goal was to slightly reduce the bundle size by sharing common `svelte` imports.

This is still supported for backwards compatibility, but it is deprecated and just re-exports the Picker. Instead, do:

While this option can reduce your bundle size, note that it only works with compatible Svelte versions. Currently Svelte v3 and v4 are supported.
```js
import Picker from 'emoji-picker-element/picker';
```

## Data and offline

Expand Down
4 changes: 2 additions & 2 deletions bin/bundlesize.js
Expand Up @@ -5,8 +5,8 @@ import { promisify } from 'node:util'
import prettyBytes from 'pretty-bytes'
import fs from 'node:fs/promises'

const MAX_SIZE_MIN = '42.7 kB'
const MAX_SIZE_MINGZ = '15 kB'
const MAX_SIZE_MIN = '37 kB'
const MAX_SIZE_MINGZ = '13 kB'

const FILENAME = './bundle.js'

Expand Down
9 changes: 9 additions & 0 deletions config/minifyHtmlInJest.js
@@ -0,0 +1,9 @@
import { minifyHTMLLiterals } from 'minify-html-literals'

export default {
processAsync (source, fileName) {
return minifyHTMLLiterals(source, {
fileName
})
}
}
5 changes: 0 additions & 5 deletions config/svelte.config.js

This file was deleted.

16 changes: 2 additions & 14 deletions jest.config.cjs
Expand Up @@ -4,15 +4,9 @@ module.exports = {
'<rootDir>/test/spec/**/*.{spec,test}.{js,jsx,ts,tsx}'
],
transform: {
'^.+\\.svelte$': ['svelte-jester', {
preprocess: './config/svelte.config.js',
compilerOptions: {
dev: false
}
}]
'^.*PickerTemplate.js$': './config/minifyHtmlInJest.js'
},
moduleFileExtensions: ['js', 'svelte'],
extensionsToTreatAsEsm: ['.svelte'],
moduleFileExtensions: ['js'],
testPathIgnorePatterns: ['node_modules'],
bail: true,
verbose: true,
Expand All @@ -31,12 +25,6 @@ module.exports = {
branches: 100,
functions: 100,
lines: 100
},
'./src/picker/components/Picker/Picker.svelte': {
statements: 90,
branches: 85,
functions: 90,
lines: 90
}
}
}
12 changes: 5 additions & 7 deletions package.json
Expand Up @@ -62,8 +62,7 @@
"custom",
"element",
"web",
"component",
"svelte"
"component"
],
"author": "Nolan Lawson <nolan@nolanlawson.com>",
"license": "Apache-2.0",
Expand Down Expand Up @@ -101,17 +100,16 @@
"jest-environment-jsdom": "^29.7.0",
"lint-staged": "^15.2.0",
"lodash-es": "^4.17.15",
"magic-string": "^0.30.5",
"markdown-table": "^3.0.2",
"markdown-toc": "^1.2.0",
"minify-html-literals": "^1.3.5",
"npm-run-all": "^4.1.5",
"playwright": "^1.40.1",
"pretty-bytes": "^6.1.1",
"puppeteer": "^21.6.0",
"recursive-readdir": "^2.2.3",
"rollup": "^4.7.0",
"rollup-plugin-analyzer": "^4.0.0",
"rollup-plugin-svelte": "^7.1.6",
"rollup-plugin-terser": "^7.0.2",
"sass": "^1.69.5",
"shx": "^0.3.4",
Expand All @@ -120,9 +118,6 @@
"stylelint": "^15.11.0",
"stylelint-config-recommended-scss": "^13.1.0",
"stylelint-scss": "^5.3.1",
"svelte": "^4.2.8",
"svelte-jester": "^3.0.0",
"svelte-preprocess": "^5.1.1",
"svgo": "^3.0.5",
"tachometer": "^0.7.0",
"terser": "^5.26.0"
Expand Down Expand Up @@ -151,11 +146,14 @@
"Event",
"fetch",
"getComputedStyle",
"Element",
"indexedDB",
"IDBKeyRange",
"Headers",
"HTMLElement",
"matchMedia",
"Node",
"NodeFilter",
"performance",
"ResizeObserver",
"Response",
Expand Down
97 changes: 15 additions & 82 deletions rollup.config.js
@@ -1,31 +1,14 @@
import MagicString from 'magic-string'
import inject from '@rollup/plugin-inject'
import cjs from '@rollup/plugin-commonjs'
import resolve from '@rollup/plugin-node-resolve'
import replace from '@rollup/plugin-replace'
import strip from '@rollup/plugin-strip'
import svelte from 'rollup-plugin-svelte'
import preprocess from 'svelte-preprocess'
import analyze from 'rollup-plugin-analyzer'
import { buildStyles } from './bin/buildStyles.js'
import { minifyHTMLLiterals } from 'minify-html-literals'

const { NODE_ENV, DEBUG } = process.env
const dev = NODE_ENV !== 'production'

const preprocessConfig = preprocess()

const origMarkup = preprocessConfig.markup
// minify the HTML by removing extra whitespace
// TODO: this is fragile, but it also results in a lot of bundlesize savings. let's find a better solution
preprocessConfig.markup = async function () {
const res = await origMarkup.apply(this, arguments)

// remove whitespace
res.code = res.code.replace(/([>}])\s+([<{])/sg, '$1$2')

return res
}

// Build Database.test.js and Picker.js as separate modules at build times so that they are properly tree-shakeable.
// Most of this has to happen because customElements.define() has side effects
const baseConfig = {
Expand All @@ -43,25 +26,18 @@ const baseConfig = {
delimiters: ['', ''],
preventAssignment: true
}),
svelte({
compilerOptions: {
dev,
discloseVersion: false
},
preprocess: preprocessConfig
}),
// make the svelte output slightly smaller
replace({
'options.anchor': 'undefined',
'options.context': 'undefined',
'options.customElement': 'undefined',
'options.hydrate': 'undefined',
'options.intro': 'undefined',
delimiters: ['', ''],
preventAssignment: true
}),
{
name: 'minify-html-in-tag-template-literals',
transform (content, id) {
if (id.includes('PickerTemplate.js')) {
return minifyHTMLLiterals(content, {
fileName: id
})
}
}
},
strip({
include: ['**/*.js', '**/*.svelte'],
include: ['**/*.js'],
functions: [
(!dev && !process.env.PERF) && 'performance.*',
!dev && 'console.log'
Expand All @@ -78,18 +54,7 @@ const baseConfig = {
const entryPoints = [
{
input: './src/picker/PickerElement.js',
output: './picker.js',
plugins: [
// Replace newer syntax in Svelte v4 to avoid breaking iOS <13.4
// https://github.com/nolanlawson/emoji-picker-element/pull/379
replace({
'array_like_or_iterator?.length': 'array_like_or_iterator && array_like_or_iterator.length',
'$$ = undefined;': '', // not necessary to initialize class prop to undefined
'$$set = undefined;': '', // not necessary to initialize class prop to undefined
delimiters: ['', ''],
preventAssignment: true
})
]
output: './picker.js'
},
{
input: './src/database/Database.js',
Expand All @@ -103,42 +68,10 @@ const entryPoints = [
input: './src/trimEmojiData.js',
output: './trimEmojiData.cjs',
format: 'cjs'
},
{
input: './src/picker/PickerElement.js',
output: './svelte.js',
external: ['svelte', 'svelte/internal'],
// TODO: drop Svelte v3 support
// ensure_array_like was added in Svelte v4 - we shim it to avoid breaking Svelte v3 users
plugins: [
{
name: 'svelte-v3-compat',
transform (source) {
const magicString = new MagicString(source)
magicString.replaceAll('ensure_array_like(', 'ensure_array_like_shim(')

return {
code: magicString.toString(),
map: magicString.generateMap()
}
}
},
inject({
ensure_array_like_shim: [
'../../../../shims/svelte-v3-shim.js',
'ensure_array_like_shim'
]
})
],
onwarn (warning) {
if (!warning.message.includes('ensure_array_like')) { // intentionally ignore warning for unused import
console.warn(warning.message)
}
}
}
]

export default entryPoints.map(({ input, output, format = 'es', external = [], plugins = [], onwarn }) => {
export default entryPoints.map(({ input, output, format = 'es', external = [], onwarn }) => {
return {
input,
output: {
Expand All @@ -148,7 +81,7 @@ export default entryPoints.map(({ input, output, format = 'es', external = [], p
exports: 'auto'
},
external: [...baseConfig.external, ...external],
plugins: [...baseConfig.plugins, ...plugins],
plugins: baseConfig.plugins,
onwarn
}
})
9 changes: 0 additions & 9 deletions shims/svelte-v3-shim.js

This file was deleted.

12 changes: 5 additions & 7 deletions src/picker/PickerElement.js
@@ -1,8 +1,9 @@
import SveltePicker from './components/Picker/Picker.svelte'
import { createRoot } from './components/Picker/Picker.js'
import { DEFAULT_DATA_SOURCE, DEFAULT_LOCALE } from '../database/constants'
import { DEFAULT_CATEGORY_SORTING, DEFAULT_SKIN_TONE_EMOJI, FONT_FAMILY } from './constants'
import enI18n from './i18n/en.js'
import Database from './ImportedDatabase'
import { queueMicrotask } from './utils/queueMicrotask.js'

const PROPS = [
'customEmoji',
Expand Down Expand Up @@ -51,17 +52,14 @@ export default class PickerElement extends HTMLElement {
// The _cmp may be defined if the component was immediately disconnected and then reconnected. In that case,
// do nothing (preserve the state)
if (!this._cmp) {
this._cmp = new SveltePicker({
target: this.shadowRoot,
props: this._ctx
})
this._cmp = createRoot(this.shadowRoot, this._ctx)
}
}

disconnectedCallback () {
// Check in a microtask if the element is still connected. If so, treat this as a "move" rather than a disconnect
// Inspired by Vue: https://vuejs.org/guide/extras/web-components.html#building-custom-elements-with-vue
Promise.resolve().then(() => {
queueMicrotask(() => {
// this._cmp may be defined if connect-disconnect-connect-disconnect occurs synchronously
if (!this.isConnected && this._cmp) {
this._cmp.$destroy()
Expand Down Expand Up @@ -110,7 +108,7 @@ export default class PickerElement extends HTMLElement {
// Update the Database in one microtask if the locale/dataSource change. We do one microtask
// so we don't create two Databases if e.g. both the locale and the dataSource change
_dbFlush () {
Promise.resolve().then(() => (
queueMicrotask(() => (
this._dbCreate()
))
}
Expand Down

0 comments on commit 5699285

Please sign in to comment.