A normal vacuum will also defragment page contents, see PageRepairFragmentation()[1], which is called during normal vacuum from lazy_vacuum_page().
[1] https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f...
[1] https://git.postgresql.org/gitweb/?p=postgresql.git;a=blob;f...
VACUUM FULL essentially rebuilds the segment files - it creates new files and shovels all rows from the old ones. In the past it was implemented differently, by actually moving rows (a bit like defrag tools in Windows) but that was very expensive and inefficient.