When I originally wrote this answer FormData was not widely supported (and this was called out explicitly in the question). Now that it's been 6 years, FormData has excellent cross-browser support.
Because of this, I highly recommend using FormData directly to access data or serialize data to the server.
Jake Archibald has an excellent post describing FormData (and URLSearchParams) in depth, which I won't attempt to reproduce here, however I will include a few snippets that are relevant:
You can populate FormData state directly:
const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');
...you can read an HTML form directly as FormData:
const formElement = document.querySelector('form');
const formData = new FormData(formElement);
console.log(formData.get('username'));
...you can use FormData directly as a fetch body:
const formData = new FormData();
formData.set('foo', 'bar');
formData.set('hello', 'world');
fetch(url, {
  method: 'POST',
  body: formData,
});
I recommend reading Jake's post in full and using the APIs that come with the browser over adding more code to build a less-resilient version of the same thing.
My original post preserved for posterity's sake:
Without a strong definition of what should happen with edge cases, and what level of browser support is required, it's difficult to give a single perfect answer to the question.
There are a lot of form behaviors that are easy to miss, which is why I recommend using a well-maintained function from a library, such as jQuery's serializeArray():
$('form').serializeArray();
I understand that there's a big push recently to move away from needlessly including jQuery, so for those who are interested in a vanilla JS solution serializeArray just won't do.
The next difficulty comes from determining what level of browser support is required. HTMLFormElement.elements significantly simplifies a serialization implementation, and selecting the form-associated elements without it is quite a pain.
Consider:
<form id="example">...</form>
<input type="text" form="example" name="lorem" value="ipsum"/>
A conforming implementation needs to include the input element. I will assume that I can use it, and leave polyfilling it as an exercise to the reader.
After that it'd unclear how <input type="file"/> should be supported. I'm not keen on needlessly serializing file elements into a string, so I've made the assumption that the serialization will be of the input's name and value, even though the value is practically useless.
Lastly, an input structure of:
{
    'input name': 'value',
    'textarea name': 'value'
}
Is excessively naive as it doesn't account for <select multiple> elements, or cases where two inputs have the same name. I've made the assumption that the input would be better as:
[
    {
        name: 'input name',
        value: 'value'
    },
    {
        name: 'textarea name',
        value: 'value'
    }
]
...and again leave transforming this into a different structure as an exercise for the reader.
Give me teh codez already!
var serialize = (function (slice) {
    return function (form) {
        //no form, no serialization
        if (form == null)
            return null;
        //get the form elements and convert to an array
        return slice.call(form.elements)
            .filter(function (element) {
                //remove disabled elements
                return !element.disabled;
            }).filter(function (element) {
                //remove unchecked checkboxes and radio buttons
                return !/^input$/i.test(element.tagName) || !/^(?:checkbox|radio)$/i.test(element.type) || element.checked;
            }).filter(function (element) {
                //remove <select multiple> elements with no values selected
                return !/^select$/i.test(element.tagName) || element.selectedOptions.length > 0;
            }).map(function (element) {
                switch (element.tagName.toLowerCase()) {
                    case 'checkbox':
                    case 'radio':
                        return {
                            name: element.name,
                            value: element.value === null ? 'on' : element.value
                        };
                    case 'select':
                        if (element.multiple) {
                            return {
                                name: element.name,
                                value: slice.call(element.selectedOptions)
                                    .map(function (option) {
                                        return option.value;
                                    })
                            };
                        }
                        return {
                            name: element.name,
                            value: element.value
                        };
                    default:
                        return {
                            name: element.name,
                            value: element.value || ''
                        };
                }
            });
    }
}(Array.prototype.slice));